From a9eaaf1fba8e8c76e070a1cadf7dfa3ea7aa774f Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Tue, 1 Sep 2026 13:43:20 +0300 Subject: [PATCH 01/33] feat: add @upstash/mcp-tasks, a durable MCP Tasks runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-28 MCP spec made the protocol stateless and moved long-running tools to the Tasks extension, but the official TypeScript SDK v2 ships the wire schemas with no runtime behind them. This adds one. `createTaskLayer({ store, dispatcher })` turns a tool into a task-returning tool and serves `tasks/get` / `tasks/cancel`, over two swappable interfaces: a `TaskStore` for the record and a `TaskDispatcher` for the execution. The split is the point — a durable task id does not make the work durable. `@upstash/mcp-tasks/upstash` implements both on Upstash Redis (one hash per task, PEXPIRE for TTL) and QStash (durable at-least-once delivery to an execute endpoint), so a process killed mid-task still finishes the work. Notable behaviour, all verified against the real SDK, real Redis and real QStash rather than inferred: - Terminal transitions go through a guarded, atomic `settle` (a Lua script on Redis), so a client's cancel and the executor completing cannot clobber each other; first terminal write wins. - The store keeps one hash field per property, not one JSON blob, so a progress update and a cancel never overwrite each other's fields. - `executeTask(id, { isFinalAttempt })` keeps a task `working` until the dispatcher's last delivery. Settling `failed` on the first error makes it terminal and silently turns every retry into a no-op. - The QStash retry delay defaults to exponential backoff. A flat 1s delay exhausts five retries in ~10s, which a restart outlives — the task then dead-letters while still reading `working` (observed, then fixed). - Clients resolve on first use, not in the constructor, so a Next.js production build that imports route modules without credentials still builds. Two SDK gotchas are documented and worked around: `createMcpHandler` answers `tasks/*` with -32601 before reaching a handler (hence the transport-based route, plus a `methods` option to namespace them), and `McpServer` flattens anything a tool callback throws into an isError result, dropping the code — so the missing-capability refusal carries -32021 in structuredContent. Also adds examples/mcp-tasks-demo: a Next.js app whose page is the MCP client, showing the task lifecycle and the raw JSON-RPC wire log. Claude-Session: https://claude.ai/code/session_01YGNUfzDFbQoteRB65VwMJU --- .changeset/spotty-donkeys-shave.md | 13 + CLAUDE.md | 55 ++- README.md | 9 +- examples/mcp-tasks-demo/.env.example | 17 + examples/mcp-tasks-demo/.gitignore | 4 + examples/mcp-tasks-demo/README.md | 84 ++++ .../mcp-tasks-demo/app/api/execute/route.ts | 53 +++ examples/mcp-tasks-demo/app/api/mcp/route.ts | 35 ++ examples/mcp-tasks-demo/app/globals.css | 386 ++++++++++++++++ examples/mcp-tasks-demo/app/layout.tsx | 18 + examples/mcp-tasks-demo/app/lib/mcp-client.ts | 146 +++++++ examples/mcp-tasks-demo/app/lib/tasks.ts | 77 ++++ examples/mcp-tasks-demo/app/page.tsx | 288 ++++++++++++ examples/mcp-tasks-demo/next-env.d.ts | 6 + examples/mcp-tasks-demo/next.config.ts | 5 + examples/mcp-tasks-demo/package.json | 28 ++ examples/mcp-tasks-demo/scripts/smoke.mjs | 99 +++++ examples/mcp-tasks-demo/tsconfig.json | 41 ++ packages/mcp-tasks/README.md | 222 ++++++++++ packages/mcp-tasks/package.json | 74 ++++ packages/mcp-tasks/src/core.test.ts | 412 ++++++++++++++++++ packages/mcp-tasks/src/core.ts | 411 +++++++++++++++++ packages/mcp-tasks/src/index.ts | 40 ++ packages/mcp-tasks/src/memory.ts | 105 +++++ packages/mcp-tasks/src/telemetry.ts | 54 +++ packages/mcp-tasks/src/test-support.ts | 41 ++ packages/mcp-tasks/src/types.ts | 159 +++++++ packages/mcp-tasks/src/upstash.test.ts | 184 ++++++++ packages/mcp-tasks/src/upstash.ts | 325 ++++++++++++++ packages/mcp-tasks/src/version.ts | 2 + packages/mcp-tasks/tsconfig.json | 8 + packages/mcp-tasks/tsup.config.ts | 13 + pnpm-lock.yaml | 127 ++++++ scripts/sync-version.mjs | 1 + 34 files changed, 3539 insertions(+), 3 deletions(-) create mode 100644 .changeset/spotty-donkeys-shave.md create mode 100644 examples/mcp-tasks-demo/.env.example create mode 100644 examples/mcp-tasks-demo/.gitignore create mode 100644 examples/mcp-tasks-demo/README.md create mode 100644 examples/mcp-tasks-demo/app/api/execute/route.ts create mode 100644 examples/mcp-tasks-demo/app/api/mcp/route.ts create mode 100644 examples/mcp-tasks-demo/app/globals.css create mode 100644 examples/mcp-tasks-demo/app/layout.tsx create mode 100644 examples/mcp-tasks-demo/app/lib/mcp-client.ts create mode 100644 examples/mcp-tasks-demo/app/lib/tasks.ts create mode 100644 examples/mcp-tasks-demo/app/page.tsx create mode 100644 examples/mcp-tasks-demo/next-env.d.ts create mode 100644 examples/mcp-tasks-demo/next.config.ts create mode 100644 examples/mcp-tasks-demo/package.json create mode 100644 examples/mcp-tasks-demo/scripts/smoke.mjs create mode 100644 examples/mcp-tasks-demo/tsconfig.json create mode 100644 packages/mcp-tasks/README.md create mode 100644 packages/mcp-tasks/package.json create mode 100644 packages/mcp-tasks/src/core.test.ts create mode 100644 packages/mcp-tasks/src/core.ts create mode 100644 packages/mcp-tasks/src/index.ts create mode 100644 packages/mcp-tasks/src/memory.ts create mode 100644 packages/mcp-tasks/src/telemetry.ts create mode 100644 packages/mcp-tasks/src/test-support.ts create mode 100644 packages/mcp-tasks/src/types.ts create mode 100644 packages/mcp-tasks/src/upstash.test.ts create mode 100644 packages/mcp-tasks/src/upstash.ts create mode 100644 packages/mcp-tasks/src/version.ts create mode 100644 packages/mcp-tasks/tsconfig.json create mode 100644 packages/mcp-tasks/tsup.config.ts diff --git a/.changeset/spotty-donkeys-shave.md b/.changeset/spotty-donkeys-shave.md new file mode 100644 index 0000000..a376e4a --- /dev/null +++ b/.changeset/spotty-donkeys-shave.md @@ -0,0 +1,13 @@ +--- +"@upstash/mcp-tasks": minor +--- + +Add `@upstash/mcp-tasks`: a durable MCP Tasks runtime for the official TypeScript SDK. + +The 2026-07-28 spec made MCP stateless and moved long-running tools to the Tasks extension, but the +official v2 SDK ships the wire schemas with no runtime behind them. This package adds one: +`createTaskLayer({ store, dispatcher })` turns a tool into a task-returning tool and serves +`tasks/get` and `tasks/cancel`, over two swappable interfaces — a `TaskStore` for the record and a +`TaskDispatcher` for the execution. `@upstash/mcp-tasks/upstash` provides both on Upstash Redis +(one hash per task, `PEXPIRE` for TTL) and QStash (durable at-least-once delivery to your execute +endpoint), so work survives the process that accepted the tool call. diff --git a/CLAUDE.md b/CLAUDE.md index c0b7828..2cf65d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,8 +19,11 @@ embeddings — keep that in mind when naming/among scoring. | `@upstash/agentkit-eve` (`packages/eve`) | Eve framework adapter. Depends on the ai-sdk package. | | `@upstash/agentkit-eve-extension` (`packages/eve-extension`) | AgentKit as a mountable **eve extension** (eve ≥0.24): one `agent/extensions/.ts` file composes memory tools, search tools, a chat-history hook, and an instructions fragment under `__*`. | +| `@upstash/mcp-tasks` (`packages/mcp-tasks`) | A durable **MCP Tasks** runtime for the official `@modelcontextprotocol/server` v2. **Not an `agentkit-*` package** — separate name, versioned independently (the changesets `linked` glob only covers `@upstash/agentkit-*`), and it depends on none of the others. | + Examples (`examples/`): `ai-sdk-demo` (hand-written Next.js), `eve-demo` (a real `eve` CLI scaffold), -and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). +`eve-extension-demo` (a minimal eve scaffold that mounts the extension), and `mcp-tasks-demo` +(Next.js; the MCP server plus a browser client that shows the JSON-RPC wire log). `langchain` and `tanstack-ai` packages were **removed** — don't reintroduce them. ### Core SDK exports (`@upstash/agentkit-sdk`) @@ -309,6 +312,56 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). `$and/$or/$must/$should/$mustNot`. Aggregations: `$terms`, `$stats`, `$sum`, `$avg`, `$min`, `$max`, `$count`, `$histogram`, `$percentiles`, `$cardinality`. +## MCP Tasks facts (`packages/mcp-tasks`) — IMPORTANT +Verified empirically against `@modelcontextprotocol/server@2.0.0`; don't re-derive them from the docs. +- **The SDK has schemas but no tasks runtime.** v2 ships `Task`/`GetTaskRequest`/`CreateTaskResult` + etc. and `isTaskAugmentedRequestParams`, but registers **no** `tasks/*` handler and has no store. + v1's experimental task APIs were removed with no migration path. That gap is what this package fills. +- **`createMcpHandler` cannot serve `tasks/get`/`tasks/cancel`.** It pins the request to the + 2026-07-28 era from the client's `_meta` protocol-version claim, and that era's dispatch gate + returns **`-32601` before the handler is looked up**: those strings are in the SDK's *2025* method + registry (so `isSpecRequestMethod` is true) and absent from the *2026* one. A `fallbackRequestHandler` + does not help — the gate returns first. Proven: the registered handler never runs, while a + namespaced `upstash/tasks.get` on the same server dispatches fine. + **So the demo and the docs use `WebStandardStreamableHTTPServerTransport` + `transport.handleRequest`**, + which leaves the instance on the 2025 era where `tasks/*` dispatch normally. `createTaskLayer`'s + `methods` option is the escape hatch for `createMcpHandler` users. +- **`supportedProtocolVersions: [TASKS_PROTOCOL_VERSION]` on the `McpServer` is required**, or the + transport rejects every 2026-07-28 request with "Unsupported protocol version" (its default list is + the 2025 era's). There is no *public* 2026 constant in the SDK — `SUPPORTED_PROTOCOL_VERSIONS` is + legacy-only and `LATEST_PROTOCOL_VERSION` is `"2025-11-25"`. +- **The per-request envelope works on both eras:** `ctx.mcpReq.envelope[CLIENT_CAPABILITIES_META_KEY]` + carries the lifted client capabilities. That is the capability check — there is no session to ask. +- **A tool callback cannot return a JSON-RPC error.** `McpServer` catches everything a tool callback + throws — `ProtocolError` and `MissingRequiredClientCapabilityError` included — and flattens it to + `{content, isError:true}`, **dropping the code**. So the missing-capability refusal is a structured + tool error with `structuredContent: { code: -32021, requiredCapabilities }`, not a thrown error. +- **`resultType: "task"` from `tools/call` is allowed** (`tools/call` is in the SDK's + `EXTENDED_RESULT_TYPE_METHODS`, forwarded verbatim). We return the task **flattened**, not under a + `task` key: `"task"` is a hard-coded "foreign family" key that blocks the SDK's contentless-result + default, so `{resultType:"task", task:{…}}` without `content` is rejected — the flattened form gets + `content: []` filled in automatically. +- **Design choices that differ from the naive version** (all covered by tests): + `TaskStore.settle` is a *guarded, atomic* terminal transition (a Lua script on Redis) so a client's + `tasks/cancel` and the executor completing cannot clobber each other — first terminal write wins; + the store keeps **one hash field per task property** (not one JSON blob) so a progress `update` and + a cancel never overwrite each other's fields; and `executeTask(id, {isFinalAttempt})` keeps a task + **`working`** until the dispatcher's last delivery, because settling `failed` on the first error + makes it terminal and every retry then no-ops on the redelivery guard. +- **Redis encoding:** every hash field is written `JSON.stringify`d and read back with **no decode of + our own** — `@upstash/redis` auto-`JSON.parse`s responses, so the single parse is the exact inverse. + Decoding again turns a `statusMessage` of `"123"` into the number `123` (this actually happened). +- **QStash retry budget must outlast a restart.** `Upstash-Retried` (count so far, from 0) is the only + retry header; there is no max-retries header, so `isFinalQStashAttempt(headers, dispatcher.retries)` + takes the configured max. The default `retryDelay` is **exponential** (`"pow(2, retried) * 1000"`): + with a flat `"1000"` a kill-9'd server exhausts all retries in ~10s and the task is dead-lettered + while still reading `working` — observed, then fixed, then re-verified end to end (kill -9 mid-task → + restart → QStash redelivery → `completed`). +- Tests: `src/core.test.ts` drives a real `McpServer` + real transport over genuine JSON-RPC; + `src/upstash.test.ts` hits real Redis. Both run under the root vitest config. +- **Local dev needs the QStash dev server** (`npx @upstash/qstash-cli dev`) — it prints deterministic + creds. `APP_URL` must be reachable *from QStash*. + ## Eve framework facts - The repo is on **`eve@0.47.3`** everywhere (`packages/eve`, `packages/eve-extension`, `examples/eve-demo`, `examples/eve-extension-demo`). `packages/eve`'s peer stays diff --git a/README.md b/README.md index ccc0ebf..27eff64 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ are powered by [Upstash Redis Search](https://upstash.com/docs/redis/search/intr | [`@upstash/agentkit-ai-sdk`](./packages/ai-sdk) | Adapter for the [Vercel AI SDK](https://ai-sdk.dev). | | [`@upstash/agentkit-eve`](./packages/eve) | Adapter for the Vercel Eve framework. | | [`@upstash/agentkit-eve-extension`](./packages/eve-extension) | The same capabilities as a mountable [Eve extension](https://eve.dev/docs/extensions) — one file in `agent/extensions/` adds memory tools, search tools, and durable chat history the agent can search. | +| [`@upstash/mcp-tasks`](./packages/mcp-tasks) | A durable [MCP Tasks](https://github.com/modelcontextprotocol/ext-tasks) runtime for the official TypeScript SDK: long-running tools answer with a task handle, the record lives in Redis, and the work runs through QStash. | ## Core features @@ -28,13 +29,17 @@ are powered by [Upstash Redis Search](https://upstash.com/docs/redis/search/intr - **Code sandbox** (Eve only) — a drop-in [Upstash Box](https://github.com/upstash/box) backend for Eve's `defineSandbox`. - **Tool-call cache** — memoize deterministic tool results keyed by arguments. +- **Durable MCP tasks** (`@upstash/mcp-tasks`) — a long-running MCP tool returns a task handle + instead of blocking; the task record lives in Redis and the work runs through QStash, so it + survives the process that accepted the call. ## Examples Runnable demos (real Upstash Redis + a mock/real model) live in [`examples/`](./examples): -[`ai-sdk-demo`](./examples/ai-sdk-demo), [`eve-demo`](./examples/eve-demo), and +[`ai-sdk-demo`](./examples/ai-sdk-demo), [`eve-demo`](./examples/eve-demo), [`eve-extension-demo`](./examples/eve-extension-demo) (an eve agent that mounts -`@upstash/agentkit-eve-extension`). +`@upstash/agentkit-eve-extension`), and [`mcp-tasks-demo`](./examples/mcp-tasks-demo) (an MCP server +whose long-running tool returns a task handle, with the client's wire log on screen). ## Development diff --git a/examples/mcp-tasks-demo/.env.example b/examples/mcp-tasks-demo/.env.example new file mode 100644 index 0000000..65023f2 --- /dev/null +++ b/examples/mcp-tasks-demo/.env.example @@ -0,0 +1,17 @@ +# Upstash Redis — the durable task record. +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= + +# QStash — the durable execution transport. +# +# For local development run `npx @upstash/qstash-cli dev` in another terminal. It prints a URL, a +# token and both signing keys; paste them here. For a deployed app use the real values from the +# Upstash console instead and drop QSTASH_URL. +QSTASH_URL=http://127.0.0.1:8080 +QSTASH_TOKEN= +QSTASH_CURRENT_SIGNING_KEY= +QSTASH_NEXT_SIGNING_KEY= + +# Where QStash delivers a task. Must be reachable *from QStash*: the local dev server can reach +# 127.0.0.1, the hosted service cannot — use your deployment URL or a tunnel there. +APP_URL=http://127.0.0.1:3000 diff --git a/examples/mcp-tasks-demo/.gitignore b/examples/mcp-tasks-demo/.gitignore new file mode 100644 index 0000000..3556585 --- /dev/null +++ b/examples/mcp-tasks-demo/.gitignore @@ -0,0 +1,4 @@ +node_modules +.next +.env* +!.env.example diff --git a/examples/mcp-tasks-demo/README.md b/examples/mcp-tasks-demo/README.md new file mode 100644 index 0000000..2d3e30a --- /dev/null +++ b/examples/mcp-tasks-demo/README.md @@ -0,0 +1,84 @@ +# MCP Tasks demo + +A Next.js app showing `@upstash/mcp-tasks` end to end: an MCP tool that answers with a task handle +instead of blocking, a task record in Upstash Redis, and the work running through QStash so it +survives the process that accepted the call. + +The page is the MCP client. It speaks raw stateless JSON-RPC to `/api/mcp` — no initialize +handshake, no session id — and shows every frame it sends and receives in a wire log next to the +tasks, so you can watch the protocol rather than just the result. + +## What's here + +| File | What it does | +| --- | --- | +| `app/lib/tasks.ts` | The whole server wiring: `RedisTaskStore`, `QStashDispatcher`, and the `generate_report` task tool | +| `app/api/mcp/route.ts` | The MCP endpoint, over `WebStandardStreamableHTTPServerTransport` | +| `app/api/execute/route.ts` | Where QStash delivers a task — verifies the signature, then runs it | +| `app/page.tsx` | The client: call the tool, poll, cancel, and the wire log | +| `scripts/smoke.mjs` | Drives the same flow from the terminal and asserts on it | + +## Run it + +You need an [Upstash Redis database](https://upstash.com/start-redis). QStash you can run locally, +fully offline. + +```bash +cp .env.example .env.local # fill in UPSTASH_REDIS_REST_URL / _TOKEN + +pnpm qstash # terminal 1 — prints the QStash URL, token and signing keys + # paste those four into .env.local +pnpm dev # terminal 2 +``` + +Then open http://localhost:3000, type a topic, and hit **Run tool**. + +`APP_URL` is the one setting worth reading twice: it is where QStash delivers the task, so it has to +be reachable *from QStash*. The local dev server can reach `127.0.0.1`; the hosted service cannot, +so a deployed app needs its real URL (or a tunnel) there. + +To check everything from the terminal instead: + +```bash +pnpm smoke # happy path, cancel mid-flight, a client without the capability, unknown task id +``` + +## The three things worth watching + +**A tool call returns immediately.** `tools/call` comes back in milliseconds with +`resultType: "task"` and a `working` status. The four-step report takes about ten seconds; none of +it happens inside that request. + +**Cancel is cooperative, in three layers.** Hit **cancel** mid-run and the store flips the status +to `cancelled`, the dispatcher cancels the pending QStash message, and the handler stops at its +next step boundary. The last layer is the one you cannot skip: running code only stops where it +checks. A completion arriving after the cancel is refused — terminal states are final. + +**The work is durable, not just the record.** Start a task and kill the dev server mid-run: + +```bash +pnpm dev +# start a task in the browser, then, a few seconds in: +kill -9 $(lsof -ti tcp:3000) +pnpm dev +``` + +Keep polling (the page resumes on its own) and the task still reaches `completed`. Redis kept the +record; QStash's redelivery is what finished the work. Replace the dispatcher with a +fire-and-forget promise and the same test leaves a permanently `working` task instead. + +One caveat this demo learned the hard way: the retry budget has to outlast your restart. QStash +retries on its configured schedule and dead-letters the message when they run out, so with a flat +one-second delay every attempt is spent within a few seconds — long before a dev server is back up, +leaving a task that reads `working` forever. The dispatcher defaults to exponential backoff +(1s, 2s, 4s, 8s, 16s) for that reason. If a task ends up dead-lettered anyway, it is in the QStash +DLQ, not lost. + +## Notes + +- The tool is an ordinary MCP tool. Nothing in `tools/list` marks it as a task; the server decides + per call, from the capabilities the request carries. +- A client that has not declared `io.modelcontextprotocol/tasks` gets a structured tool error + telling it what to declare, instead of a task it cannot poll. +- The route uses `WebStandardStreamableHTTPServerTransport` rather than `createMcpHandler` on + purpose — see the note in `app/api/mcp/route.ts` and the package README. diff --git a/examples/mcp-tasks-demo/app/api/execute/route.ts b/examples/mcp-tasks-demo/app/api/execute/route.ts new file mode 100644 index 0000000..38d7da1 --- /dev/null +++ b/examples/mcp-tasks-demo/app/api/execute/route.ts @@ -0,0 +1,53 @@ +/** + * The endpoint QStash delivers a task to. + * + * This is where the work actually runs — in a different request, and possibly a different process, + * from the `tools/call` that created the task. That separation is the whole point: the process + * that accepted the call can die without taking the work with it. + */ +import { Receiver } from "@upstash/qstash"; +import { isFinalQStashAttempt } from "@upstash/mcp-tasks/upstash"; +import { dispatcher, EXECUTE_URL, tasks } from "../../lib/tasks"; + +export const dynamic = "force-dynamic"; +// The demo tool sleeps for ~10s; give the platform room to let it finish. +export const maxDuration = 60; + +const receiver = new Receiver({ + currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY ?? "", + nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY ?? "", +}); + +export async function POST(request: Request): Promise { + const body = await request.text(); + + // Without this, anyone who can reach the route can run tasks. + try { + await receiver.verify({ + signature: request.headers.get("upstash-signature") ?? "", + body, + url: EXECUTE_URL, + }); + } catch (cause) { + console.error("[execute] signature verification failed", cause); + // 401 is deliberate: a bad signature is not something a retry can fix. + return new Response("invalid signature", { status: 401 }); + } + + const { taskId } = JSON.parse(body) as { taskId?: string }; + if (!taskId) return new Response("missing taskId", { status: 400 }); + + // Whether a thrown handler is fatal depends on whether QStash will try again. + const isFinalAttempt = isFinalQStashAttempt(request.headers, dispatcher.retries); + + try { + console.log(`[execute] task=${taskId} starting (final attempt: ${isFinalAttempt})`); + const task = await tasks.executeTask(taskId, { isFinalAttempt }); + console.log(`[execute] task=${taskId} -> ${task?.status ?? "gone"}`); + return new Response("ok"); + } catch (cause) { + console.error(`[execute] task=${taskId} failed`, cause); + // A non-2xx is how you ask QStash to retry. + return new Response("retry", { status: 500 }); + } +} diff --git a/examples/mcp-tasks-demo/app/api/mcp/route.ts b/examples/mcp-tasks-demo/app/api/mcp/route.ts new file mode 100644 index 0000000..34abb1a --- /dev/null +++ b/examples/mcp-tasks-demo/app/api/mcp/route.ts @@ -0,0 +1,35 @@ +/** + * The MCP endpoint. + * + * Note this uses `WebStandardStreamableHTTPServerTransport` rather than `createMcpHandler`. Both + * take a web `Request` and return a `Response`, but `createMcpHandler` pins the request to the + * 2026-07-28 era, and on that era the SDK's dispatch gate answers `tasks/get` and `tasks/cancel` + * with `-32601` before your handler is ever looked up. See `TASK_METHODS` in `@upstash/mcp-tasks` + * for the details and the namespaced-method workaround. + */ +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"; +import { createServer } from "../../lib/tasks"; + +// Every request builds its own server and transport: the protocol is stateless now, so there is +// nothing to keep between requests, and any instance can serve any request. +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + const server = createServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + // No SSE stream here, so a keepalive would only leak a timer per request. + keepAliveMs: 0, + }); + await server.connect(transport); + + try { + const response = await transport.handleRequest(request); + // Buffer the body before closing, so the response cannot be cut off by the close below. + const body = await response.text(); + return new Response(body, { status: response.status, headers: response.headers }); + } finally { + await transport.close(); + } +} diff --git a/examples/mcp-tasks-demo/app/globals.css b/examples/mcp-tasks-demo/app/globals.css new file mode 100644 index 0000000..980e43e --- /dev/null +++ b/examples/mcp-tasks-demo/app/globals.css @@ -0,0 +1,386 @@ +:root { + --bg: #0b0f0e; + --panel: #111716; + --panel-2: #161d1c; + --line: #223029; + --text: #e8f2ee; + --muted: #8ba39a; + --accent: #00e9a3; + --accent-dim: #0c6b52; + --amber: #ffc857; + --red: #ff6b6b; + --blue: #6bb8ff; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: + ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +a { + color: var(--accent); +} + +.shell { + max-width: 1180px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +header.masthead { + border-bottom: 1px solid var(--line); + padding-bottom: 20px; + margin-bottom: 24px; +} + +header.masthead h1 { + margin: 0 0 6px; + font-size: 22px; + letter-spacing: -0.01em; +} + +header.masthead p { + margin: 0; + color: var(--muted); + max-width: 70ch; +} + +.pill { + display: inline-block; + font-family: var(--mono); + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--line); + color: var(--muted); + margin-right: 6px; +} + +.grid { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; +} + +.panel > h2 { + margin: 0; + padding: 12px 16px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + border-bottom: 1px solid var(--line); + background: var(--panel-2); + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.panel-body { + padding: 16px; +} + +form.launch { + display: flex; + gap: 8px; +} + +input[type="text"] { + flex: 1; + min-width: 0; + background: var(--bg); + border: 1px solid var(--line); + color: var(--text); + border-radius: 8px; + padding: 10px 12px; + font-size: 14px; + font-family: inherit; +} + +input[type="text"]:focus { + outline: none; + border-color: var(--accent-dim); +} + +button { + background: var(--accent); + color: #04231b; + border: 0; + border-radius: 8px; + padding: 10px 16px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + font-family: inherit; +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +button.ghost { + background: transparent; + border: 1px solid var(--line); + color: var(--muted); + padding: 5px 10px; + font-size: 12px; + font-weight: 500; +} + +button.ghost:hover:not(:disabled) { + color: var(--text); + border-color: var(--muted); +} + +.hint { + color: var(--muted); + font-size: 12px; + margin-top: 10px; +} + +.tasks { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.task { + border: 1px solid var(--line); + border-radius: 10px; + padding: 14px; + background: var(--panel-2); +} + +.task-head { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.task-id { + font-family: var(--mono); + font-size: 12px; + color: var(--muted); +} + +.task-topic { + font-weight: 600; +} + +.spacer { + flex: 1; +} + +.badge { + font-family: var(--mono); + font-size: 11px; + padding: 3px 9px; + border-radius: 999px; + border: 1px solid currentColor; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.badge.working { + color: var(--blue); +} + +.badge.input_required { + color: var(--amber); +} + +.badge.completed { + color: var(--accent); +} + +.badge.failed { + color: var(--red); +} + +.badge.cancelled { + color: var(--muted); +} + +.status-line { + margin-top: 10px; + color: var(--muted); + font-size: 13px; + min-height: 20px; +} + +.bar { + margin-top: 10px; + height: 4px; + background: #0a1210; + border-radius: 999px; + overflow: hidden; +} + +.bar > span { + display: block; + height: 100%; + background: var(--accent); + transition: width 0.4s ease; +} + +.bar.cancelled > span { + background: var(--muted); +} + +.bar.failed > span { + background: var(--red); +} + +.meta { + margin-top: 10px; + display: flex; + gap: 14px; + flex-wrap: wrap; + font-family: var(--mono); + font-size: 11px; + color: var(--muted); +} + +.result { + margin-top: 12px; + border-top: 1px dashed var(--line); + padding-top: 12px; +} + +.result p { + margin: 0 0 8px; +} + +pre { + margin: 0; + font-family: var(--mono); + font-size: 11.5px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + color: var(--text); +} + +.log { + max-height: 620px; + overflow-y: auto; + padding: 8px 0; +} + +.frame { + padding: 8px 16px; + border-bottom: 1px solid #1a2422; +} + +.frame:last-child { + border-bottom: 0; +} + +.frame-head { + display: flex; + gap: 8px; + align-items: baseline; + font-family: var(--mono); + font-size: 11px; +} + +.frame-head .dir { + color: var(--accent); + font-weight: 700; +} + +.frame-head .dir.in { + color: var(--blue); +} + +.frame-head .method { + color: var(--text); +} + +.frame-head .time { + color: #4d635b; + margin-left: auto; +} + +.frame pre { + margin-top: 5px; + color: var(--muted); + font-size: 11px; +} + +.empty { + color: var(--muted); + font-size: 13px; + padding: 8px 0; +} + +.callout { + margin-top: 20px; + border: 1px solid var(--line); + border-left: 3px solid var(--accent-dim); + border-radius: 8px; + padding: 14px 16px; + background: var(--panel); +} + +.callout h3 { + margin: 0 0 6px; + font-size: 13px; +} + +.callout p { + margin: 0 0 8px; + color: var(--muted); + font-size: 13px; +} + +.callout code, +.inline-code { + font-family: var(--mono); + font-size: 12px; + background: #0a1210; + border: 1px solid var(--line); + border-radius: 4px; + padding: 1px 5px; +} + +.banner { + border: 1px solid #5a3a2a; + background: #2a1a12; + color: #ffd9a8; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 20px; + font-size: 13px; +} diff --git a/examples/mcp-tasks-demo/app/layout.tsx b/examples/mcp-tasks-demo/app/layout.tsx new file mode 100644 index 0000000..a4a6156 --- /dev/null +++ b/examples/mcp-tasks-demo/app/layout.tsx @@ -0,0 +1,18 @@ +import "./globals.css"; +import type { ReactNode } from "react"; + +export const metadata = { + title: "MCP Tasks on Upstash", + description: + "Durable long-running MCP tools: a task record in Upstash Redis, execution on QStash.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + +
{children}
+ + + ); +} diff --git a/examples/mcp-tasks-demo/app/lib/mcp-client.ts b/examples/mcp-tasks-demo/app/lib/mcp-client.ts new file mode 100644 index 0000000..a99523e --- /dev/null +++ b/examples/mcp-tasks-demo/app/lib/mcp-client.ts @@ -0,0 +1,146 @@ +/** + * A hand-rolled MCP client for the browser. + * + * The official `@modelcontextprotocol/client` validates `tools/call` responses against its own + * result schemas, which do not yet accept the tasks extension's `resultType: "task"` discriminator + * — so a task handle comes back as a validation error rather than a handle. Until that lands, + * talking raw JSON-RPC is the honest way to demo the extension, and it has the side benefit of + * showing exactly what a stateless MCP request looks like now. + */ +export const PROTOCOL_VERSION = "2026-07-28"; +export const TASKS_EXTENSION = "io.modelcontextprotocol/tasks"; +export const MCP_ENDPOINT = "/api/mcp"; + +export type TaskStatus = "working" | "input_required" | "completed" | "failed" | "cancelled"; + +export type WireTask = { + taskId: string; + status: TaskStatus; + statusMessage?: string; + createdAt: string; + lastUpdatedAt: string; + ttlMs: number | null; + pollIntervalMs?: number; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +}; + +export const TERMINAL: ReadonlySet = new Set([ + "completed", + "failed", + "cancelled", +]); + +/** One direction of one JSON-RPC exchange, for the wire log. */ +export type Frame = { + id: number; + direction: "out" | "in"; + method: string; + payload: unknown; + at: number; +}; + +let frameId = 0; +let requestId = 0; + +export type RpcOptions = { + /** Called once for the request and once for the response, so the UI can render the wire. */ + onFrame?: (frame: Frame) => void; +}; + +/** + * Sends one stateless JSON-RPC request. + * + * There is no initialize handshake and no session header any more: the protocol version, who the + * client is, and which extensions it supports all ride in `_meta` on every single request. The + * server reads the capabilities from there to decide whether it may answer a tool call with a + * task handle. + */ +export async function rpc>( + method: string, + params: Record, + options: RpcOptions = {}, +): Promise { + const body = { + jsonrpc: "2.0" as const, + id: ++requestId, + method, + params: { + ...params, + _meta: { + "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, + "io.modelcontextprotocol/clientInfo": { name: "mcp-tasks-demo", version: "0.1.0" }, + "io.modelcontextprotocol/clientCapabilities": { + extensions: { [TASKS_EXTENSION]: {} }, + }, + }, + }, + }; + + const headers: Record = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": PROTOCOL_VERSION, + "mcp-method": method, + }; + // The spec routes on a name header so a load balancer never has to parse the body: the tool + // name for a call, the task id for the task methods. + if (method === "tools/call" && typeof params.name === "string") headers["mcp-name"] = params.name; + if (method.startsWith("tasks/") && typeof params.taskId === "string") { + headers["mcp-name"] = params.taskId; + } + + options.onFrame?.({ id: ++frameId, direction: "out", method, payload: body, at: Date.now() }); + + const response = await fetch(MCP_ENDPOINT, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + const message = await readMessage(response); + options.onFrame?.({ + id: ++frameId, + direction: "in", + method, + payload: message, + at: Date.now(), + }); + + if (message?.error) throw new RpcError(method, message.error); + if (!response.ok) throw new Error(`${method} failed with HTTP ${response.status}`); + return message?.result as T; +} + +export class RpcError extends Error { + constructor( + readonly method: string, + readonly rpcError: { code?: number; message?: string; data?: unknown }, + ) { + super(`${method}: ${rpcError?.message ?? "unknown error"}`); + this.name = "RpcError"; + } +} + +type JsonRpcResponse = { result?: unknown; error?: { code?: number; message?: string } } | undefined; + +/** + * Reads either shape the transport may answer with: a plain JSON body, or a one-message SSE + * stream when the server decides to stream the response. + */ +async function readMessage(response: Response): Promise { + const contentType = response.headers.get("content-type") ?? ""; + const text = await response.text(); + if (!text) return undefined; + + if (!contentType.includes("text/event-stream")) { + return JSON.parse(text) as JsonRpcResponse; + } + + for (const line of text.split(/\r?\n/)) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data) return JSON.parse(data) as JsonRpcResponse; + } + return undefined; +} diff --git a/examples/mcp-tasks-demo/app/lib/tasks.ts b/examples/mcp-tasks-demo/app/lib/tasks.ts new file mode 100644 index 0000000..e2bf9f2 --- /dev/null +++ b/examples/mcp-tasks-demo/app/lib/tasks.ts @@ -0,0 +1,77 @@ +/** + * The whole server-side wiring: a store, a dispatcher, and one task tool. + * + * Both routes import from here — `/api/mcp` to serve the protocol, `/api/execute` to run the work + * QStash delivers back. + */ +import { McpServer } from "@modelcontextprotocol/server"; +import { createTaskLayer, TASKS_PROTOCOL_VERSION } from "@upstash/mcp-tasks"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; +import * as z from "zod"; + +/** Where QStash delivers a task. It has to be reachable *from QStash*, not just from your browser. */ +export const EXECUTE_URL = `${process.env.APP_URL ?? "http://127.0.0.1:3000"}/api/execute`; + +export const dispatcher = new QStashDispatcher({ url: EXECUTE_URL, retries: 5 }); + +export const tasks = createTaskLayer({ + // Both default to `fromEnv()`, so there is no client to thread through. + store: new RedisTaskStore(), + dispatcher, + defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, +}); + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const STEPS = 4; + +/** + * Builds a server with the demo's task tool on it. + * + * A fresh one per request: the transport below is stateless, and an `McpServer` owns the single + * transport it is connected to. + */ +export function createServer(): McpServer { + const server = new McpServer( + { name: "upstash-mcp-tasks-demo", version: "0.1.0" }, + // Without this the transport validates the request's `mcp-protocol-version` header against + // the SDK's 2025-era list and rejects every 2026-07-28 request before it reaches a handler. + { supportedProtocolVersions: [TASKS_PROTOCOL_VERSION] }, + ); + + tasks.registerTask( + server, + "generate_report", + { + title: "Generate report", + description: `Generates a report on a topic in ${STEPS} durable steps. Returns a task handle immediately.`, + inputSchema: z.object({ topic: z.string().describe("What the report should be about") }), + completedMessage: "Report ready", + }, + async ({ topic }, task) => { + for (let step = 1; step <= STEPS; step++) { + // Cancellation is cooperative: running code only stops where it checks, so the check + // goes at every step boundary. + if (await task.isCancelled()) { + console.log(`[execute] task=${task.taskId} cancelled before step ${step}`); + return {}; + } + await task.update(`Step ${step}/${STEPS}: processing ${topic}`); + await sleep(2_500); + } + + return { + content: [{ type: "text", text: `Report complete: ${topic}` }], + structuredContent: { report: `A concise report about ${topic}.` }, + }; + }, + ); + + return server; +} + +// The delivery endpoint receives only a task id and looks the handler up by the tool name stored +// on the task — so the handler registry has to be populated even when `/api/execute` is the first +// route hit in this process. Registering once at module load does that; the server built here is +// never connected to a transport. +createServer(); diff --git a/examples/mcp-tasks-demo/app/page.tsx b/examples/mcp-tasks-demo/app/page.tsx new file mode 100644 index 0000000..8eab887 --- /dev/null +++ b/examples/mcp-tasks-demo/app/page.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + rpc, + TASKS_EXTENSION, + TERMINAL, + type Frame, + type WireTask, +} from "./lib/mcp-client"; + +const TOOL_NAME = "generate_report"; + +type TrackedTask = { + taskId: string; + topic: string; + startedAt: number; + lastPolledAt: number; + polls: number; + wire: WireTask; +}; + +export default function Page() { + const [topic, setTopic] = useState("coffee trends"); + const [tasks, setTasks] = useState([]); + const [frames, setFrames] = useState([]); + const [tools, setTools] = useState(null); + const [starting, setStarting] = useState(false); + const [error, setError] = useState(null); + const [, setTick] = useState(0); + + const onFrame = useCallback((frame: Frame) => { + setFrames(previous => [frame, ...previous].slice(0, 80)); + }, []); + + // A plain `tools/list` — the task tool is an ordinary MCP tool. Nothing about its declaration + // says "task"; the server decides per call whether to answer with a handle. + useEffect(() => { + rpc<{ tools: { name: string }[] }>("tools/list", {}, { onFrame }) + .then(result => setTools(result.tools.map(tool => tool.name))) + .catch(cause => setError(String(cause))); + }, [onFrame]); + + // Re-render once a second so the elapsed counters move. + useEffect(() => { + const id = setInterval(() => setTick(n => n + 1), 1000); + return () => clearInterval(id); + }, []); + + const tasksRef = useRef(tasks); + tasksRef.current = tasks; + + // The client polls; the server has nothing to push. Each task carries its own + // `pollIntervalMs`, so the server sets the pace rather than the UI guessing. + useEffect(() => { + const id = setInterval(() => { + const now = Date.now(); + for (const task of tasksRef.current) { + if (TERMINAL.has(task.wire.status)) continue; + if (now - task.lastPolledAt < (task.wire.pollIntervalMs ?? 2000)) continue; + markPolled(task.taskId); + void poll(task.taskId); + } + }, 400); + return () => clearInterval(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function markPolled(taskId: string) { + setTasks(previous => + previous.map(task => (task.taskId === taskId ? { ...task, lastPolledAt: Date.now() } : task)), + ); + } + + async function poll(taskId: string) { + try { + const wire = await rpc("tasks/get", { taskId }, { onFrame }); + setTasks(previous => + previous.map(task => + task.taskId === taskId ? { ...task, wire, polls: task.polls + 1 } : task, + ), + ); + } catch (cause) { + setError(String(cause)); + } + } + + async function start(event: React.FormEvent) { + event.preventDefault(); + if (!topic.trim() || starting) return; + setStarting(true); + setError(null); + try { + // A normal `tools/call`. Because the request declared the tasks extension in its + // capabilities, the server answers with a handle instead of blocking for ten seconds. + const wire = await rpc( + "tools/call", + { name: TOOL_NAME, arguments: { topic: topic.trim() } }, + { onFrame }, + ); + setTasks(previous => [ + { + taskId: wire.taskId, + topic: topic.trim(), + startedAt: Date.now(), + lastPolledAt: Date.now(), + polls: 0, + wire, + }, + ...previous, + ]); + } catch (cause) { + setError(String(cause)); + } finally { + setStarting(false); + } + } + + async function cancel(taskId: string) { + try { + await rpc("tasks/cancel", { taskId }, { onFrame }); + await poll(taskId); + } catch (cause) { + setError(String(cause)); + } + } + + return ( + <> +
+

MCP Tasks on Upstash

+

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

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

Call the tool

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

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

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

No tasks yet. Run the tool to create one.

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

Prove the work is durable, not just the record

+

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

+
+
+ +
+

+ Wire log + +

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

+ Nothing sent yet. +

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

{wire.statusMessage ?? "—"}

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

Result, carried inline on the final poll:

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

Failed:

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

Call the tool

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

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

- {TASKS_EXTENSION} + task_status · task_cancel 2026-07-28 tools/list → {tools ? (tools.join(", ") || "none") : "…"} diff --git a/examples/mcp-tasks-demo/scripts/smoke.mjs b/examples/mcp-tasks-demo/scripts/smoke.mjs index 7a0d4eb..32b44da 100644 --- a/examples/mcp-tasks-demo/scripts/smoke.mjs +++ b/examples/mcp-tasks-demo/scripts/smoke.mjs @@ -1,13 +1,12 @@ -// Drives the demo the way the browser does: raw stateless JSON-RPC. +// Drives the demo the way a model would: plain tools/call, from a client that declares nothing. 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 } = {}) { +async function rpc(method, params = {}) { const headers = { "content-type": "application/json", accept: "application/json, text/event-stream", @@ -15,7 +14,6 @@ async function rpc(method, params = {}, { caps = true } = {}) { "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, @@ -28,7 +26,7 @@ async function rpc(method, params = {}, { caps = true } = {}) { _meta: { "io.modelcontextprotocol/protocolVersion": PV, "io.modelcontextprotocol/clientInfo": { name: "e2e", version: "1.0.0" }, - "io.modelcontextprotocol/clientCapabilities": caps ? { extensions: { [EXT]: {} } } : {}, + "io.modelcontextprotocol/clientCapabilities": {}, }, }, }), @@ -38,64 +36,56 @@ async function rpc(method, params = {}, { caps = true } = {}) { return json.result; } -const brief = t => +const call = (name, args) => rpc("tools/call", { name, arguments: args }); + +const brief = r => JSON.stringify({ - status: t.status, - statusMessage: t.statusMessage, - ...(t.result ? { result: t.result } : {}), + status: r.structuredContent?.status, + statusMessage: r.structuredContent?.statusMessage, + ...(r.structuredContent?.result ? { result: r.structuredContent.result } : {}), }); console.log(`== tools/list (${ENDPOINT}) ==`); -const list = await rpc("tools/list"); -console.log(list.tools.map(t => t.name).join(", ")); +const names = (await rpc("tools/list")).tools.map(t => t.name); +console.log(names.join(", ")); +for (const expected of ["generate_report", "task_status", "task_cancel"]) { + if (!names.includes(expected)) throw new Error(`missing tool ${expected}`); +} console.log("\n== 1. happy path =="); -const created = await rpc("tools/call", { - name: "generate_report", - arguments: { topic: "coffee trends" }, -}); +const created = await call("generate_report", { topic: "coffee trends" }); console.log("created", JSON.stringify(created)); -if (created.resultType !== "task") throw new Error("expected a task handle"); +const taskId = created.structuredContent?.taskId; +if (!taskId) 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 }); + last = await call("task_status", { taskId }); console.log("poll ", brief(last)); - if (["completed", "failed", "cancelled"].includes(last.status)) break; + if (["completed", "failed", "cancelled"].includes(last.structuredContent?.status)) break; +} +if (last.structuredContent?.status !== "completed") { + throw new Error(`expected completed, got ${last.structuredContent?.status}`); } -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); +const second = await call("generate_report", { topic: "tea rituals" }); +const secondId = second.structuredContent.taskId; +console.log("created", secondId); await sleep(3000); -const cancelled = await rpc("tasks/cancel", { taskId: second.taskId }); -console.log("cancel", brief(cancelled)); +console.log("cancel", brief(await call("task_cancel", { taskId: secondId }))); await sleep(6000); -const afterCancel = await rpc("tasks/get", { taskId: second.taskId }); +const afterCancel = await call("task_status", { taskId: secondId }); 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)); +if (afterCancel.structuredContent?.status !== "cancelled") { + throw new Error(`expected cancelled, got ${afterCancel.structuredContent?.status}`); } +if (afterCancel.structuredContent?.result) throw new Error("a cancelled task must not carry a result"); + +console.log("\n== 3. unknown task =="); +const unknown = await call("task_status", { taskId: "does-not-exist" }); +console.log("unknown", JSON.stringify(unknown)); +if (!unknown.isError) throw new Error("expected a tool error"); console.log("\nALL E2E CHECKS PASSED"); diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md index 5447801..26b4ad9 100644 --- a/packages/mcp-tasks/README.md +++ b/packages/mcp-tasks/README.md @@ -1,19 +1,15 @@ # @upstash/mcp-tasks -A durable [MCP Tasks](https://github.com/modelcontextprotocol/ext-tasks) runtime for the official -TypeScript SDK. +Durable long-running tools for MCP servers on 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. +A long-running tool answers immediately with a task id instead of blocking. The model polls a +shared `task_status` tool for progress and, once it completes, the result. The task record lives +in Upstash Redis; the work runs through QStash or Upstash Workflow, so it survives the process that +accepted the call, and it is not bound by your function's time limit or the client's tool timeout. -> `@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. +Everything is served as **ordinary MCP tools**, so it works in every client today — Claude Code, +Codex, Cursor, OpenCode, ChatGPT — with no client capability required. See +[Why tools, not the Tasks extension?](#why-tools-not-the-tasks-extension) ## Install @@ -24,8 +20,9 @@ npm install @upstash/mcp-tasks @modelcontextprotocol/server @upstash/redis @upst ## Usage ```ts +// lib/tasks.ts import { McpServer } from "@modelcontextprotocol/server"; -import { createTaskLayer, TASKS_PROTOCOL_VERSION } from "@upstash/mcp-tasks"; +import { createTaskLayer } from "@upstash/mcp-tasks"; import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; import * as z from "zod"; @@ -35,15 +32,12 @@ export const tasks = createTaskLayer({ }); export function createServer() { - const server = new McpServer( - { name: "reports", version: "1.0.0" }, - { supportedProtocolVersions: [TASKS_PROTOCOL_VERSION] }, - ); + const server = new McpServer({ name: "reports", version: "1.0.0" }); tasks.registerTask( server, "generate_report", - { description: "Generates a report", inputSchema: z.object({ topic: z.string() }) }, + { description: "Generates a report on a topic.", inputSchema: z.object({ topic: z.string() }) }, async ({ topic }) => ({ content: [{ type: "text", text: await writeReport(topic) }] }), ); @@ -51,7 +45,38 @@ export function createServer() { } ``` -Everything above is required. Progress messages and cancellation are opt-in: +Then two routes — the MCP endpoint, which is the SDK's own handler unchanged, and the one the work +is delivered to: + +```ts +// app/api/mcp/route.ts +import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createServer } from "../../lib/tasks"; + +const handler = createMcpHandler(() => createServer()); +export const POST = (request: Request) => handler.fetch(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. Because the layer only registers tools, it works the same with +[`mcp-handler`](https://www.npmjs.com/package/mcp-handler) or any transport. + +`tools/list` now shows three tools: + +| Tool | What it does | +| --- | --- | +| `generate_report` | Starts the task and answers with its `taskId` at once | +| `task_status` | Progress while `working`; the handler's own result once `completed` | +| `task_cancel` | Asks the task to stop; idempotent | + +`task_status` and `task_cancel` are shared by every task tool on the server.
Reporting progress and honouring cancellation @@ -70,38 +95,67 @@ async ({ topic }, task) => { }; ``` -`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. +`task.update(...)` is what the model sees as `statusMessage` on its next `task_status`. +Cancellation is cooperative: `task_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: +## Multi-user servers: set `principal` -```ts -// app/api/mcp/route.ts -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"; -import { createServer } from "../../lib/tasks"; +Without it, anyone holding a task id can read and cancel that task. With it, each task records its +owner, and `task_status` / `task_cancel` answer only for the caller who started it — another +caller's id reads exactly like an unknown one. -export async function POST(request: Request) { - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, - }); - await createServer().connect(transport); - return transport.handleRequest(request); -} +```ts +const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + // Receives the AuthInfo your auth middleware attached to the request. + principal: (auth) => auth?.extra?.userId as string | undefined, +}); ``` +Key on the *user*, not `auth.clientId`: the client id identifies the OAuth app, which is often one +id shared by every user of a host like ChatGPT. + +## Retries without duplicates: `idempotencyKey` + +Agents retry tool calls, especially ones that seemed to time out. Give a task tool an +`idempotencyKey`, and a second call from the same caller with the same key returns the existing +task instead of starting another: + ```ts -// app/api/execute/route.ts -import { tasks } from "../../lib/tasks"; +tasks.registerTask(server, "generate_report", { + description: "Generates a report on a topic.", + inputSchema: z.object({ topic: z.string() }), + idempotencyKey: ({ topic }) => topic, +}, handler); +``` -export const POST = tasks.createExecuteHandler(); +Keys are scoped by caller and tool, and last as long as the task is retained (`ttlMs`). Return +`undefined` to opt a call out. + +## What the model sees + +```jsonc +// generate_report → a handle, immediately +{ "content": [{ "type": "text", "text": "Started task 0e30…. Call task_status with taskId \"0e30…\" in about 2s to check on it." }], + "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 300000, "pollIntervalMs": 2000 } } + +// task_status → progress… +{ "content": [{ "type": "text", "text": "Task 0e30… is working: Reading source 2. Check again in about 2s." }], + "structuredContent": { "taskId": "0e30…", "status": "working", "statusMessage": "Reading source 2" } } + +// …then the handler's own content, as if the tool had run synchronously +{ "content": [{ "type": "text", "text": "Task 0e30… is completed: Completed" }, + { "type": "text", "text": "Report on coffee" }], + "structuredContent": { "taskId": "0e30…", "status": "completed", "result": { "content": [ … ] } } } ``` -That second route is deliberately not yours to write — the dispatcher owns it. See the -[FAQ](#faq) for what it does. +The states are `working`, `completed`, `failed` and `cancelled` (plus `input_required`, reserved); +the last three are terminal and never change again. The task object is the same shape as the +protocol's Tasks extension, so a native adapter can serve these records unchanged later. ## Choosing a dispatcher @@ -156,21 +210,6 @@ step body executed exactly once.**
-## 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
@@ -178,7 +217,7 @@ three are terminal and never change again. | | 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` | +| **`@upstash/mcp-tasks`** | The tools: creating the record before replying, `task_status` / `task_cancel`, ownership and idempotency, 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 | @@ -188,28 +227,25 @@ The split is the whole design: a durable task id does not make the underlying wo
-Flow: tools/call → a task handle +Flow: starting a task ```mermaid sequenceDiagram - participant C as Client + participant M as Model 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) + M->>S: tools/call generate_report S->>St: create(task) - Note over St: must commit before the reply —
a tasks/get may hit another instance + Note over St: must commit before the reply —
the next poll may hit another instance St-->>S: ok S->>D: dispatch(taskId) D-->>S: dispatchId S->>St: update({ dispatchId }) - S-->>C: resultType "task" + handle + S-->>M: taskId + "call task_status" ``` -Order is the spec's, not a preference: the record must be durable before the handle goes out. -
@@ -224,7 +260,6 @@ sequenceDiagram 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) @@ -242,120 +277,44 @@ retrying.
-Flow: tasks/get and tasks/cancel +Flow: task_status and task_cancel ```mermaid sequenceDiagram - participant C as Client + participant M as Model participant S as mcp-tasks participant St as TaskStore participant D as TaskDispatcher - C->>S: tasks/get { taskId } + M->>S: task_status { taskId } S->>St: get(taskId) - St-->>S: task (or null → -32602) - S-->>C: resultType "complete" + public fields + S->>S: owner matches? else "unknown task" + S-->>M: status, or the result once completed - C->>S: tasks/cancel { taskId } + M->>S: task_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 + S-->>M: 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. +## Why tools, not the Tasks extension? -### Three gaps +The 2026-07-28 spec defines a Tasks extension (`io.modelcontextprotocol/tasks`): a `tools/call` +answers with `resultType: "task"`, and the client polls `tasks/get`. It is the right long-term +shape — the *client* polls, so the model spends no turns on it. But a server must never return a +task to a client that has not declared the extension, and as of October 2026 none of the clients +people actually use do: not Claude Code, Codex, Cursor or OpenCode. Of the official SDKs only Rust +and C# implement it; TypeScript and Python have it on their roadmaps. -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. +Plain tools trade some polling turns for working everywhere today. The model is told to poll, gets +a suggested interval, and receives the result in the same shape it would have synchronously. -**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. - -
+The store and dispatcher do not care which surface sits on top. When clients declare the +extension, a native adapter can answer the same records over `tasks/get` / `tasks/cancel` for those +clients, and keep the tools for everyone else. ## Reference @@ -396,12 +355,12 @@ neither is durable, which is exactly the failure this package is about. | --- | --- | | `store`, `dispatcher` | Required. | | `defaults.ttlMs` | Retention window, `null` for unlimited. Default 5 min. | -| `defaults.pollIntervalMs` | Poll interval to suggest to clients. Default 2s. | -| `onMissingCapability` | `"error"` (default) or `"run-inline"` — run the handler and answer normally for a client that cannot poll. | -| `methods` | Rename the task methods. Needed only with `createMcpHandler`; see the FAQ. | +| `defaults.pollIntervalMs` | Poll interval suggested to the model. Default 2s. | +| `principal` | `(auth) => string \| undefined` — scopes tasks to their caller. Set it on any multi-user server. | +| `toolNames` | Rename `task_status` / `task_cancel`, e.g. to namespace them. | **`registerTask` config** — `description`, `inputSchema`, plus optional `title`, `ttlMs`, -`pollIntervalMs`, `queuedMessage`, `completedMessage`. +`pollIntervalMs`, `queuedMessage`, `completedMessage`, `idempotencyKey`. **`RedisTaskStore`** — `redis` (defaults to `Redis.fromEnv()`), `prefix`, `enableTelemetry`. @@ -416,12 +375,12 @@ neither is durable, which is exactly the failure this package is about. | Export | What it is | | --- | --- | -| `createTaskLayer(options)` | `{ registerTask, executeTask, failTask, createExecuteHandler, getTask, store, dispatcher }` | +| `createTaskLayer(options)` | `{ registerTask, executeTask, failTask, createExecuteHandler, getTask, cancelTask, 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 | +| `Task`, `WireTask`, `TaskStatus`, `TaskError` | The record, and the subset the model sees | | `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 | +| `DEFAULT_TOOL_NAMES`, `CallerAuth` | `{ status: "task_status", cancel: "task_cancel" }`, and what `principal` receives | | `MemoryTaskStore`, `InlineTaskDispatcher` | Non-durable backends for tests | | `@upstash/mcp-tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher` | @@ -455,62 +414,12 @@ A dispatcher that runs work in-process — `InlineTaskDispatcher` — has no end
-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: +Won't the model give up polling? -```jsonc -{ "isError": true, - "content": [{ "type": "text", "text": "\"generate_report\" answers with a task handle, which requires …" }], - "structuredContent": { "code": -32021, - "requiredCapabilities": { "extensions": { "io.modelcontextprotocol/tasks": {} } } } } -``` +Sometimes, which is why every response says what to do next in words — "call `task_status` in +about 2s" — and `task_status` tells the model not to start the task again. If it does start it +again, `idempotencyKey` turns the retry into a read of the existing task. Nothing is lost if the +model stops polling: the work finishes anyway, and the result stays readable until the task's TTL.
@@ -532,18 +441,17 @@ 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. +Without `principal`, effectively yes: ids are random (~122 bits for `randomUUID`), but anyone who +learns one can read *and cancel* that task. With `principal` set, an id is useless to anyone but +its owner. Idempotent task ids are a hash of caller, tool and key, so they are not guessable either.
## 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. +- `input_required` — a handler asking the user something mid-task. It would be the same shape: + write the question into the record, let the handler read the answer at a step boundary. +- Listing tasks. Deliberately absent: without sessions, a list is only safe once scoped by + `principal`, and the model rarely needs it. +- A native Tasks-extension adapter and an MCP Events bridge (push a `task.completed` event instead + of polling). Both sit on the same store; they wait on client support. diff --git a/packages/mcp-tasks/package.json b/packages/mcp-tasks/package.json index fbc82b6..5c48aa0 100644 --- a/packages/mcp-tasks/package.json +++ b/packages/mcp-tasks/package.json @@ -1,7 +1,7 @@ { "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.", + "description": "Durable long-running tools for MCP servers on the official TypeScript SDK: start, status and cancel tools over a pluggable task store and dispatcher, with Upstash Redis, QStash and Workflow backends.", "license": "MIT", "repository": { "type": "git", diff --git a/packages/mcp-tasks/src/backends/workflow.ts b/packages/mcp-tasks/src/backends/workflow.ts index 6830a81..1935721 100644 --- a/packages/mcp-tasks/src/backends/workflow.ts +++ b/packages/mcp-tasks/src/backends/workflow.ts @@ -45,7 +45,7 @@ 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 + * Cancellation composes: `task_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> { diff --git a/packages/mcp-tasks/src/core.test.ts b/packages/mcp-tasks/src/core.test.ts index dd572a0..28861cf 100644 --- a/packages/mcp-tasks/src/core.test.ts +++ b/packages/mcp-tasks/src/core.test.ts @@ -1,51 +1,63 @@ /** * 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. + * genuine JSON-RPC `tools/call`s 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 { createTaskLayer } from "./core.js"; import { InlineTaskDispatcher, MemoryTaskStore } from "./backends/memory.js"; -import type { TaskContext, TaskLayer, WireTask } from "./index.js"; +import type { TaskContext, TaskLayer, TaskToolConfig, 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 } }>; +const PROTOCOL_VERSION = "2026-07-28"; + +type ToolResult = { + content?: { type: string; text?: string }[]; + structuredContent?: Record; + isError?: boolean; +}; + +type Call = (name: string, args: Record, user?: string) => Promise; type Harness = { - rpc: Rpc; + call: Call; + /** Starts the report tool and returns the task id it answered with. */ + start: (topic?: string, user?: string) => Promise; + status: (taskId: string, user?: string) => Promise; + cancel: (taskId: string, user?: string) => Promise; + listTools: () => Promise; tasks: TaskLayer; store: MemoryTaskStore; dispatcher: InlineTaskDispatcher; close: () => Promise; }; +type ReportArgs = { topic: string }; + /** Builds a server with one task tool backed by `handler`. */ async function harness( - handler: (args: { topic: string }, task: TaskContext) => Promise>, + handler: (args: ReportArgs, task: TaskContext) => Promise>, layer: Partial[0]> = {}, + tool: Partial>> = {}, ): 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] }, + { supportedProtocolVersions: [PROTOCOL_VERSION] }, ); tasks.registerTask( server, "generate_report", - { description: "Generates a report", inputSchema: z.object({ topic: z.string() }) }, + { + description: "Generates a report.", + inputSchema: z.object({ topic: z.string() }), + ...tool, + }, handler, ); @@ -56,17 +68,14 @@ async function harness( await server.connect(transport); let id = 0; - const rpc: Rpc = async (method, params = {}, options = {}) => { - const { withTasksCapability = true } = options; + const rpc = async (method: string, params: Record, user?: string) => { const headers: Record = { "content-type": "application/json", accept: "application/json, text/event-stream", - "mcp-protocol-version": TASKS_PROTOCOL_VERSION, + "mcp-protocol-version": 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", @@ -78,21 +87,34 @@ async function harness( params: { ...params, _meta: { - "io.modelcontextprotocol/protocolVersion": TASKS_PROTOCOL_VERSION, + "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, "io.modelcontextprotocol/clientInfo": { name: "test", version: "1.0.0" }, - "io.modelcontextprotocol/clientCapabilities": withTasksCapability - ? { extensions: { [TASKS_EXTENSION]: {} } } - : {}, + // A client that declares nothing at all: the layer must not need any capability. + "io.modelcontextprotocol/clientCapabilities": {}, }, }, }), }), + user + ? { authInfo: { token: "t", clientId: "chatgpt", scopes: [], extra: { userId: user } } } + : undefined, ); - return JSON.parse(await response.text()); + const body = JSON.parse(await response.text()); + if (body.error) throw new Error(`${body.error.code}: ${body.error.message}`); + return body.result as Record; }; + const call: Call = async (name, args, user) => + (await rpc("tools/call", { name, arguments: args }, user)) as ToolResult; + return { - rpc, + call, + start: async (topic = "coffee trends", user) => + String((await call("generate_report", { topic }, user)).structuredContent?.taskId), + status: (taskId, user) => call("task_status", { taskId }, user), + cancel: (taskId, user) => call("task_cancel", { taskId }, user), + listTools: async () => + ((await rpc("tools/list", {})).tools as { name: string }[]).map((t) => t.name), tasks, store, dispatcher, @@ -106,7 +128,7 @@ async function harness( /** A four-step handler that cooperates with cancellation, like the demo's. */ const steppedHandler = (steps = 4, stepMs = 20) => - async ({ topic }: { topic: string }, task: TaskContext) => { + async ({ topic }: ReportArgs, task: TaskContext) => { for (let step = 1; step <= steps; step++) { if (await task.isCancelled()) return {}; await task.update(`Step ${step}/${steps}: processing ${topic}`); @@ -115,6 +137,8 @@ const steppedHandler = return { content: [{ type: "text", text: `Report complete: ${topic}` }] }; }; +const text = (result: ToolResult) => (result.content ?? []).map((c) => c.text).join("\n"); + describe("createTaskLayer over MCP", () => { let live: Harness | undefined; afterEach(async () => { @@ -122,168 +146,188 @@ describe("createTaskLayer over MCP", () => { live = undefined; }); - it("answers tools/call with a task handle and no result", async () => { + it("registers the task tool plus the shared status and cancel tools, once", async () => { live = await harness(steppedHandler()); - const { result, error } = await live.rpc("tools/call", { - name: "generate_report", - arguments: { topic: "coffee trends" }, - }); + expect((await live.listTools()).sort()).toEqual( + ["generate_report", "task_cancel", "task_status"].sort(), + ); + }); - 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("answers the tool call with a task handle, as an ordinary tool result", async () => { + live = await harness(steppedHandler()); + const result = await live.call("generate_report", { topic: "coffee trends" }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent?.status).toBe("working"); + expect(result.structuredContent?.statusMessage).toBe("Queued for durable execution"); + expect(typeof result.structuredContent?.taskId).toBe("string"); + expect(result.structuredContent?.pollIntervalMs).toBe(2_000); + // The model is told what to do next, in words. + expect(text(result)).toMatch(/task_status/); + // The handle must never leak the server's own bookkeeping. + expect(result.structuredContent).not.toHaveProperty("name"); + expect(result.structuredContent).not.toHaveProperty("args"); + expect(result.structuredContent).not.toHaveProperty("dispatchId"); + expect(result.structuredContent).not.toHaveProperty("owner"); }); 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" }, - }); + const taskId = await live.start(); // 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); + const stored = await live.store.get(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 () => { + it("returns the handler's own result content from task_status once completed", 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); - + const taskId = await live.start(); 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({ + const polled = await live.status(taskId); + expect(polled.structuredContent?.status).toBe("completed"); + expect(polled.structuredContent?.result).toEqual({ content: [{ type: "text", text: "Report complete: coffee trends" }], }); + // The model reads the answer exactly as if the tool had run synchronously. + expect(polled.content?.at(-1)).toEqual({ + 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); + const taskId = await live.start(); 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$/); - + const midway = await live.status(taskId); + expect(midway.structuredContent?.status).toBe("working"); + expect(String(midway.structuredContent?.statusMessage)).toMatch( + /^Step \d\/4: processing coffee trends$/, + ); + expect(text(midway)).toMatch(/Check again/); await live.dispatcher.drain(); }); - it("errors with -32602 for an unknown task id", async () => { + it("answers an unknown task id with a tool error, not a protocol error", 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/); + const result = await live.status("nope"); + expect(result.isError).toBe(true); + expect(text(result)).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); + const taskId = await live.start(); await sleep(70); - const cancelled = await live.rpc("tasks/cancel", { taskId }); - expect(cancelled.result?.resultType).toBe("complete"); - expect(cancelled.result?.status).toBe("cancelled"); + const cancelled = await live.cancel(taskId); + expect(cancelled.structuredContent?.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(); + // A terminal state is final: the handler's late return must not overwrite it. + const after = await live.status(taskId); + expect(after.structuredContent?.status).toBe("cancelled"); + expect(after.structuredContent?.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"); + const taskId = await live.start("x"); + await live.cancel(taskId); + const second = await live.cancel(taskId); + expect(second.isError).toBeFalsy(); + expect(second.structuredContent?.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); + const taskId = await live.start("x"); await live.dispatcher.drain(); - const after = await live.rpc("tasks/get", { taskId }); - expect(after.result?.status).toBe("cancelled"); - expect(after.result?.result).toBeUndefined(); + const after = await live.status(taskId); + expect(after.structuredContent?.status).toBe("cancelled"); + expect(after.structuredContent?.result).toBeUndefined(); + }); + }); + + describe("ownership", () => { + const principal = (auth: { extra?: Record } | undefined) => + auth?.extra?.userId as string | undefined; + + it("lets the owner read and cancel its task", async () => { + live = await harness(steppedHandler(4, 30), { principal }); + const taskId = await live.start("x", "alice"); + expect((await live.store.get(taskId))?.owner).toBe("alice"); + expect((await live.status(taskId, "alice")).structuredContent?.status).toBe("working"); + expect((await live.cancel(taskId, "alice")).structuredContent?.status).toBe("cancelled"); + await live.dispatcher.drain(); + }); + + it("reports another caller's task as unknown, and refuses to cancel it", async () => { + live = await harness(steppedHandler(4, 30), { principal }); + const taskId = await live.start("x", "alice"); + + const peek = await live.status(taskId, "mallory"); + expect(peek.isError).toBe(true); + expect(text(peek)).toMatch(/Unknown task/); + + const stop = await live.cancel(taskId, "mallory"); + expect(stop.isError).toBe(true); + expect((await live.store.get(taskId))?.status).not.toBe("cancelled"); + + // Nor does an anonymous caller get through. + expect((await live.status(taskId)).isError).toBe(true); + await live.dispatcher.drain(); + }); + + it("leaves tasks unscoped when no principal is configured", async () => { + live = await harness(steppedHandler(1, 1)); + const taskId = await live.start("x", "alice"); + expect((await live.store.get(taskId))?.owner).toBeUndefined(); + expect((await live.status(taskId, "bob")).isError).toBeFalsy(); + await live.dispatcher.drain(); + }); + }); + + describe("idempotency", () => { + it("returns the same task for a retried call with the same key", async () => { + live = await harness(steppedHandler(4, 30), {}, { idempotencyKey: (args) => args.topic }); + const first = await live.call("generate_report", { topic: "x" }); + const retry = await live.call("generate_report", { topic: "x" }); + + expect(retry.structuredContent?.taskId).toBe(first.structuredContent?.taskId); + expect(text(retry)).toMatch(/already exists/); + expect(live.dispatcher.dispatched).toBe(1); + + const other = await live.call("generate_report", { topic: "y" }); + expect(other.structuredContent?.taskId).not.toBe(first.structuredContent?.taskId); + await live.dispatcher.drain(); + }); + + it("scopes keys by owner, so two callers never share a task", async () => { + live = await harness( + steppedHandler(1, 1), + { principal: (auth) => auth?.extra?.userId as string | undefined }, + { idempotencyKey: (args) => args.topic }, + ); + const alice = await live.start("x", "alice"); + const bob = await live.start("x", "bob"); + expect(alice).not.toBe(bob); + await live.dispatcher.drain(); + }); + + it("starts a fresh task on every call without a key", async () => { + live = await harness(steppedHandler(1, 1)); + expect(await live.start("x")).not.toBe(await live.start("x")); + await live.dispatcher.drain(); }); }); @@ -294,11 +338,7 @@ describe("createTaskLayer over MCP", () => { 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); + const taskId = await live.start("x"); await live.dispatcher.drain(); expect(runs).toBe(1); @@ -308,6 +348,12 @@ describe("createTaskLayer over MCP", () => { expect(runs).toBe(1); }); + it("acknowledges a delivery for a task that no longer exists", async () => { + live = await harness(steppedHandler(1, 1)); + // Expired or never created: a retry cannot fix that, so it must not throw and be retried. + await expect(live.tasks.executeTask("gone")).resolves.toBeNull(); + }); + it("leaves a thrown task retryable rather than settling it failed", async () => { let attempts = 0; live = await harness( @@ -318,24 +364,15 @@ describe("createTaskLayer over MCP", () => { }, { dispatcher: new InlineTaskDispatcher({ autoRun: false }) }, ); - const created = await live.rpc("tools/call", { - name: "generate_report", - arguments: { topic: "x" }, - }); - const taskId = String(created.result?.taskId); + const taskId = await live.start("x"); - // Redeliveries are driven by hand here, to prove `executeTask` itself never makes a - // failure terminal — that decision belongs to the transport. + // Redeliveries are driven by hand, 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"); - + expect((await live.status(taskId)).structuredContent?.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((await live.status(taskId)).structuredContent?.status).toBe("completed"); expect(attempts).toBe(3); }); @@ -343,34 +380,28 @@ describe("createTaskLayer over MCP", () => { 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); + const taskId = await live.start("x"); 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" }); + const after = await live.status(taskId); + expect(after.structuredContent?.status).toBe("failed"); + expect(after.structuredContent?.error).toMatchObject({ code: -32603, message: "permanent" }); + expect(text(after)).toMatch(/permanent/); }); }); - it("serves the task methods under custom names when asked", async () => { + it("registers the shared tools under custom names when asked", async () => { live = await harness(steppedHandler(1, 1), { - methods: { get: "upstash/tasks.get", cancel: "upstash/tasks.cancel" }, + toolNames: { status: "upstash_task_status", cancel: "upstash_task_cancel" }, }); - const created = await live.rpc("tools/call", { - name: "generate_report", - arguments: { topic: "x" }, - }); - const taskId = String(created.result?.taskId); + expect(await live.listTools()).toEqual( + expect.arrayContaining(["upstash_task_status", "upstash_task_cancel"]), + ); + const taskId = await live.start("x"); 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"); + expect((await live.call("upstash_task_status", { taskId })).structuredContent?.status).toBe( + "completed", + ); }); it("infers handler argument types from the input schema", async () => { @@ -379,20 +410,14 @@ describe("createTaskLayer over MCP", () => { 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" }, - }); + const taskId = await live.start("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", - ); + expect((await live.status(taskId)).content?.at(-1)?.text).toBe("COFFEE"); }); }); describe("wire shape", () => { - it("keeps a WireTask assignable from what tasks/get returns", () => { + it("keeps a WireTask assignable from what task_status returns", () => { const wire: WireTask = { taskId: "t", status: "working", diff --git a/packages/mcp-tasks/src/core.ts b/packages/mcp-tasks/src/core.ts index 66c34dc..e58736a 100644 --- a/packages/mcp-tasks/src/core.ts +++ b/packages/mcp-tasks/src/core.ts @@ -1,23 +1,22 @@ /** * 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. + * Long-running work over MCP, served as **ordinary tools**. A task tool answers immediately with a + * task handle; two shared tools, `task_status` and `task_cancel`, poll and stop it. The work runs + * behind a {@link TaskDispatcher} (QStash, Upstash Workflow, or in-process), and the record lives + * in a {@link TaskStore} (Upstash Redis, or memory). * - * {@link createTaskLayer} is that runtime, in one factory over a {@link TaskStore} and a - * {@link TaskDispatcher}. + * Why tools rather than the protocol's own Tasks extension (`io.modelcontextprotocol/tasks`): as of + * October 2026 no mainstream client declares it — not Claude Code, Codex, Cursor or OpenCode — and + * a server must never return a task to a client that did not. Plain tools work in every client + * today, and they need nothing from the SDK beyond `registerTool`, so this layer serves through + * `createMcpHandler`, `mcp-handler` or any transport unchanged. The store and dispatcher are the + * same either way, so a native adapter can sit on top of them once clients catch up. + * + * {@link createTaskLayer} is the whole runtime, in one factory over a store and a dispatcher. */ -import { randomUUID } from "node:crypto"; -import { - CLIENT_CAPABILITIES_META_KEY, - ProtocolError, - ProtocolErrorCode, - type McpServer, - type StandardSchemaWithJSON, -} from "@modelcontextprotocol/server"; +import { createHash, randomUUID } from "node:crypto"; +import type { McpServer, StandardSchemaWithJSON } from "@modelcontextprotocol/server"; import * as z from "zod"; import { isTerminal, @@ -31,28 +30,22 @@ import { 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"; +/** The names the two shared tools are registered under, unless overridden. */ +export const DEFAULT_TOOL_NAMES = { status: "task_status", cancel: "task_cancel" } as const; + +/** + * The slice of the SDK's `AuthInfo` this layer reads. Typed structurally so a principal resolver + * does not need to import the SDK's auth types. + */ +export type CallerAuth = { + clientId?: string; + scopes?: string[]; + extra?: Record; + [key: string]: unknown; +}; export type TaskLayerOptions = { /** Durable storage for the task record. */ @@ -63,51 +56,36 @@ export type TaskLayerOptions = { defaults?: { /** Retention window. `null` means unlimited. Defaults to 5 minutes. */ ttlMs?: number | null; - /** Suggested client poll interval. Defaults to 2s. */ + /** Suggested poll interval, returned to the model. 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`. + * Who is calling, as a stable string — usually your user id. When it returns a value, every + * task records it as its owner, and `task_status` / `task_cancel` only answer for tasks the same + * caller owns. Another caller's task id reads exactly like an unknown one. + * + * Receives the `AuthInfo` your auth middleware attached to the request. Leave it unset only for + * a single-tenant server: without it, anyone holding a task id can read and cancel that task. + * + * ```ts + * principal: (auth) => auth?.extra?.userId as string | undefined + * ``` * - * Override them only if you serve through `createMcpHandler`. See {@link TASK_METHODS} for why - * the defaults cannot work there. + * Note that `auth.clientId` is the OAuth *client* (e.g. one id for every ChatGPT user), so it is + * usually the wrong key on its own. */ - methods?: { - get?: string; - cancel?: string; - }; + principal?: (auth: CallerAuth | undefined) => string | undefined; + /** Rename the two shared tools, e.g. to namespace them next to other servers' tools. */ + toolNames?: { status?: 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 = { +export type TaskToolConfig> = { /** Human-readable title for `tools/list`. */ title?: string; - /** What the tool does, for the model. */ + /** + * What the tool does, for the model. The layer appends one sentence telling the model the call + * returns a task id to poll, so this only needs to describe the work. + */ description: string; /** A Standard Schema (Zod 4, ArkType, Valibot) describing the tool's arguments. */ inputSchema: Schema; @@ -119,6 +97,16 @@ export type TaskToolConfig = { queuedMessage?: string; /** Status message set on success. Defaults to `"Completed"`. */ completedMessage?: string; + /** + * Derives an idempotency key from the arguments. Two calls from the same caller that produce + * the same key, while the first task is still retained, return the **same** task instead of + * starting a second one — which is what an agent retrying a timed-out tool call needs. + * + * Return `undefined` to opt a call out. To dedupe identical calls outright, return a stable + * serialization of the arguments; to let the model choose, add a field to your schema and + * return it here. + */ + idempotencyKey?: (args: Args) => string | undefined; }; /** Infers a Standard Schema's parsed output type. */ @@ -134,6 +122,9 @@ type InferArgs = Schema extends { * 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. + * + * Return an MCP tool result (`content`, optionally `structuredContent`). The model receives it + * from `task_status` once the task completes. */ export type TaskHandler = ( args: Args, @@ -141,7 +132,10 @@ export type TaskHandler = ( ) => Promise>; export type TaskLayer = { - /** Registers a tool whose calls are answered with a task handle. */ + /** + * Registers a tool whose calls start a task and answer with its handle. The first call on a + * server also registers the shared `task_status` and `task_cancel` tools. + */ registerTask( server: McpServer, name: string, @@ -175,8 +169,10 @@ export type TaskLayer = { * 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. */ + /** Reads a task record server-side, bypassing ownership checks. */ getTask(taskId: string): Promise; + /** Cancels a task server-side, bypassing ownership checks. Idempotent. */ + cancelTask(taskId: string): Promise; /** The store this layer was built on. */ store: TaskStore; /** The dispatcher this layer was built on. */ @@ -196,10 +192,10 @@ export type TaskLayer = { 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, + const { store, dispatcher, defaults = {}, principal } = options; + const toolNames = { + status: options.toolNames?.status ?? DEFAULT_TOOL_NAMES.status, + cancel: options.toolNames?.cancel ?? DEFAULT_TOOL_NAMES.cancel, }; // Keyed by tool name: the delivery endpoint only receives a task id, so it looks the handler up @@ -216,88 +212,119 @@ export function createTaskLayer( ): void { handlers.set(name, handler as TaskHandler); if (config.completedMessage) completedMessages.set(name, config.completedMessage); - wireTaskMethods(server); + registerSharedTools(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) }; + const owner = callerOf(context); + const key = config.idempotencyKey?.(args as InferArgs); + + // A keyed call gets a deterministic id, so a retry lands on the task the first call made. + // Scoped by owner and tool, so two callers' keys can never collide. + const taskId = key === undefined ? randomUUID() : deterministicId(owner, name, key); + if (key !== undefined) { + const existing = await store.get(taskId); + if (existing) return startedResult(existing, toolNames, true); } + + const now = new Date().toISOString(); + const task: Task = { + taskId, + 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, + ...(owner !== undefined && { owner }), + }; + + // The record must be durable before the handle goes out, because the model may poll it + // against another instance the moment it has the id. Dispatch second, so a queue that + // accepts a task can always find its record. Two racing keyed calls both get here at + // worst: they write the same record, and the dispatchers dedupe on the task id. + await store.create(task); + const dispatchId = await dispatcher.dispatch(task.taskId); + const saved = dispatchId ? await store.update(task.taskId, { dispatchId }) : task; + return startedResult(saved, toolNames, false); }; server.registerTool( name, - { title: config.title, description: config.description, inputSchema: config.inputSchema }, + { + title: config.title, + description: + `${config.description.trim()} Runs in the background: returns a taskId immediately. ` + + `Call ${toolNames.status} with it to get progress and, once completed, the result.`, + 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 { + /** Registers `task_status` and `task_cancel` on a server, once. */ + function registerSharedTools(server: McpServer): void { if (wired.has(server)) return; wired.add(server); + const inputSchema = z.object({ + taskId: z.string().describe("The taskId returned when the task was started."), + }); - 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) }; + server.registerTool( + toolNames.status, + { + title: "Task status", + description: + "Returns the status of a background task: working, completed, failed or cancelled. " + + "While it is working, wait the suggested pollIntervalMs and call again — do not start " + + "the task a second time. Once completed, the response contains the task's result.", + inputSchema, + annotations: { readOnlyHint: true, idempotentHint: true }, + }, + (async ({ taskId }: { taskId: string }, context: unknown) => { + const task = await owned(taskId, context); + return task ? statusResult(task) : unknownTaskResult(taskId); + }) as never, + ); + + server.registerTool( + toolNames.cancel, + { + title: "Cancel task", + description: + "Asks a background task to stop. Cancellation is cooperative: the task stops at its " + + "next checkpoint. Cancelling a finished task changes nothing.", + inputSchema, + annotations: { destructiveHint: true, idempotentHint: true }, + }, + (async ({ taskId }: { taskId: string }, context: unknown) => { + if (!(await owned(taskId, context))) return unknownTaskResult(taskId); + const task = await cancelTask(taskId); + return task ? statusResult(task) : unknownTaskResult(taskId); + }) as never, + ); + } + + async function cancelTask(taskId: string): Promise { + const task = await read(taskId); + if (!task) return null; + // 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 cancellation is cooperative, + // and why the handler still checks `isCancelled()`. + await dispatcher.cancel(dispatchId).catch(() => undefined); + } + return settled ?? (await read(taskId)); } async function executeTask( @@ -305,7 +332,9 @@ export function createTaskLayer( context?: TContext, journal?: TaskJournal, ): Promise { - const task = await required(taskId); + const task = await read(taskId); + // Expired, or never existed: nothing to run, and nothing a retry could fix. + if (!task) return null; // 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. @@ -364,32 +393,32 @@ export function createTaskLayer( }); } - 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; + /** Reads a task, folding "unknown" and "expired" into null. */ + async function read(taskId: string): Promise { try { - task = await store.get(taskId); + return await store.get(taskId); } catch (cause) { - if (cause instanceof UnknownTaskError) task = null; - else throw cause; + if (cause instanceof UnknownTaskError) return null; + 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; + } + + /** + * Reads a task the caller is allowed to see. A task owned by someone else is reported as + * unknown rather than forbidden, so an id cannot be probed for existence. + */ + async function owned(taskId: string, context: unknown): Promise { + const task = await read(taskId); + if (!task) return null; + if (task.owner === undefined || task.owner === null) return task; + // Compared as strings: Redis' auto-deserialization turns a numeric-looking owner into a number. + return String(task.owner) === callerOf(context) ? task : null; + } + + function callerOf(context: unknown): string | undefined { + if (!principal) return undefined; + const auth = (context as { http?: { authInfo?: CallerAuth } } | undefined)?.http?.authInfo; + return principal(auth); } function createExecuteHandler(): (request: Request) => Promise { @@ -410,55 +439,80 @@ export function createTaskLayer( executeTask, failTask, createExecuteHandler, - getTask: (taskId) => store.get(taskId), + getTask: read, + cancelTask, 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 { +/** What the starting tool answers: the handle, and a sentence telling the model what to do next. */ +function startedResult( + task: Task, + toolNames: { status: string }, + deduplicated: boolean, +): Record { + const wire = toWire(task); + const lead = deduplicated + ? `Task ${task.taskId} already exists for this request (status: ${task.status}).` + : `Started task ${task.taskId}.`; 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.`, + `${lead} Call ${toolNames.status} with taskId "${task.taskId}" ` + + `in about ${seconds(task.pollIntervalMs)} to check on it.`, }, ], - structuredContent: { - code: ProtocolErrorCode.MissingRequiredClientCapability, - requiredCapabilities: { extensions: { [TASKS_EXTENSION]: {} } }, - }, + structuredContent: wire, }; } /** - * 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`. + * What `task_status` and `task_cancel` answer. A completed task hands back its own result's + * `content`, so the model reads the answer exactly as if the tool had run synchronously. */ -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); +function statusResult(task: Task): Record { + const wire = toWire(task); + const line = `Task ${task.taskId} is ${task.status}${task.statusMessage ? `: ${task.statusMessage}` : "."}`; + + if (task.status === "completed") { + const content = Array.isArray(task.result?.content) ? task.result.content : []; + return { content: [{ type: "text", text: line }, ...content], structuredContent: wire }; + } + if (task.status === "failed") { + return { + content: [{ type: "text", text: `${line} Error: ${task.error?.message ?? "unknown"}` }], + structuredContent: wire, + }; + } + const hint = + task.status === "working" ? ` Check again in about ${seconds(task.pollIntervalMs)}.` : ""; + return { content: [{ type: "text", text: line + hint }], structuredContent: wire }; +} + +function unknownTaskResult(taskId: string): Record { + return { + isError: true, + content: [ + { + type: "text", + text: `Unknown task: ${taskId}. It may have expired, or the id may be wrong.`, + }, + ], + }; +} + +const seconds = (ms: number | undefined) => + `${Math.max(1, Math.round((ms ?? DEFAULT_POLL_INTERVAL_MS) / 1000))}s`; + +/** A stable, opaque task id for an idempotency key. */ +function deterministicId(owner: string | undefined, tool: string, key: string): string { + return createHash("sha256") + .update(JSON.stringify([owner ?? null, tool, key])) + .digest("hex") + .slice(0, 32); } /** @@ -478,8 +532,8 @@ function mergeContext( 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. */ +/** Strips the server-only fields, leaving what the model sees in `structuredContent`. */ export function toWire(task: Task): WireTask { - const { name: _name, args: _args, dispatchId: _dispatchId, ...wire } = task; + const { name: _name, args: _args, dispatchId: _dispatchId, owner: _owner, ...wire } = task; return wire; } diff --git a/packages/mcp-tasks/src/index.ts b/packages/mcp-tasks/src/index.ts index d9d08bb..feba869 100644 --- a/packages/mcp-tasks/src/index.ts +++ b/packages/mcp-tasks/src/index.ts @@ -1,5 +1,5 @@ /** - * `@upstash/mcp-tasks` — a durable MCP Tasks runtime for the official TypeScript SDK. + * `@upstash/mcp-tasks` — durable long-running tools for MCP servers on 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. @@ -7,10 +7,8 @@ export { createTaskLayer, toWire, - TASK_METHODS, - TASKS_EXTENSION, - TASKS_PROTOCOL_VERSION, - type MissingCapabilityBehavior, + DEFAULT_TOOL_NAMES, + type CallerAuth, type TaskHandler, type TaskLayer, type TaskLayerOptions, diff --git a/packages/mcp-tasks/src/types.ts b/packages/mcp-tasks/src/types.ts index 341d065..191e070 100644 --- a/packages/mcp-tasks/src/types.ts +++ b/packages/mcp-tasks/src/types.ts @@ -9,8 +9,9 @@ */ /** - * 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. + * A task's five states, borrowed from the `io.modelcontextprotocol/tasks` extension so a native + * adapter can serve the same records later. `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"; @@ -50,8 +51,9 @@ export type TaskError = { }; /** - * 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. + * Exactly the object the model sees in a task tool's `structuredContent` — the same shape as the + * Tasks extension's task object. Everything the server keeps for itself lives on {@link Task} + * instead, and is stripped on the way out. */ export type WireTask = { taskId: string; @@ -63,7 +65,7 @@ export type WireTask = { lastUpdatedAt: string; /** Retention window in milliseconds. `null` means unlimited. */ ttlMs: number | null; - /** How long the client should wait between `tasks/get` polls. */ + /** How long the model should wait between `task_status` polls. */ pollIntervalMs?: number; /** Present once the task is `completed`: the tool result, inline. */ result?: Record; @@ -72,8 +74,8 @@ export type WireTask = { }; /** - * 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. + * The stored task: the wire object plus the fields the server needs and the model never sees — + * which tool to run, what to run it with, which dispatch to cancel, and who owns it. */ export type Task = WireTask & { /** The registered task name, so the executor knows which handler to run. */ @@ -82,6 +84,11 @@ export type Task = WireTask & { args: unknown; /** The dispatcher's handle for the pending delivery, so cancel can stop retries. */ dispatchId?: string; + /** + * The caller that started the task, from `TaskLayerOptions.principal`. When set, only the same + * caller can read or cancel it through the tools. + */ + owner?: string; }; /** The fields a caller may patch on a stored task. */ @@ -94,7 +101,7 @@ 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 + * until the task is durably created, because the model may immediately poll it against a * different instance. So {@link create} must have committed before it resolves. */ export interface TaskStore { @@ -121,7 +128,7 @@ export interface TaskStore { * 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 + * it by design: a `task_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; From ad83a178e7c3c301a1b921aa83b1b97576a3906f Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Wed, 7 Oct 2026 12:12:16 +0000 Subject: [PATCH 13/33] chore(mcp-tasks): one changeset for the first release The package has never been published, but its three changesets described an evolution of APIs this PR has since removed (tasks/get, TaskSteps, isFinalAttempt). Replace them with one changeset describing the package as it ships, and start from 0.0.0 so the first release is 0.1.0, not 0.2.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd --- .changeset/hot-jars-judge.md | 19 ----------------- .changeset/mcp-tasks-initial.md | 18 ++++++++++++++++ .changeset/olive-pans-shave.md | 33 ------------------------------ .changeset/spotty-donkeys-shave.md | 13 ------------ packages/mcp-tasks/package.json | 2 +- packages/mcp-tasks/src/version.ts | 2 +- 6 files changed, 20 insertions(+), 67 deletions(-) delete mode 100644 .changeset/hot-jars-judge.md create mode 100644 .changeset/mcp-tasks-initial.md delete mode 100644 .changeset/olive-pans-shave.md delete mode 100644 .changeset/spotty-donkeys-shave.md diff --git a/.changeset/hot-jars-judge.md b/.changeset/hot-jars-judge.md deleted file mode 100644 index c9c40e2..0000000 --- a/.changeset/hot-jars-judge.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@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/mcp-tasks-initial.md b/.changeset/mcp-tasks-initial.md new file mode 100644 index 0000000..32337fd --- /dev/null +++ b/.changeset/mcp-tasks-initial.md @@ -0,0 +1,18 @@ +--- +"@upstash/mcp-tasks": minor +--- + +Add `@upstash/mcp-tasks`: durable long-running tools for MCP servers on the official TypeScript SDK. + +A task tool answers immediately with a task id; the model polls the shared `task_status` tool for +progress and the result, and can stop the task with `task_cancel`. They are ordinary MCP tools, so +they work in every client today, through `createMcpHandler` or any transport. + +The task record lives in a `TaskStore` and the work runs behind a `TaskDispatcher`. +`@upstash/mcp-tasks/upstash` provides `RedisTaskStore` (Upstash Redis), `QStashDispatcher` (one +durable delivery per task, retried with backoff) and `WorkflowDispatcher` (one invocation per step, +for work that outlives a function). The dispatcher owns its delivery endpoint: +`export const POST = tasks.createExecuteHandler()`. + +`principal` scopes tasks to their caller, and `idempotencyKey` turns an agent's retried start into a +read of the existing task. diff --git a/.changeset/olive-pans-shave.md b/.changeset/olive-pans-shave.md deleted file mode 100644 index bcbf545..0000000 --- a/.changeset/olive-pans-shave.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@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 deleted file mode 100644 index a376e4a..0000000 --- a/.changeset/spotty-donkeys-shave.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@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/packages/mcp-tasks/package.json b/packages/mcp-tasks/package.json index 5c48aa0..dbfe8a7 100644 --- a/packages/mcp-tasks/package.json +++ b/packages/mcp-tasks/package.json @@ -1,6 +1,6 @@ { "name": "@upstash/mcp-tasks", - "version": "0.1.0", + "version": "0.0.0", "description": "Durable long-running tools for MCP servers on the official TypeScript SDK: start, status and cancel tools over a pluggable task store and dispatcher, with Upstash Redis, QStash and Workflow backends.", "license": "MIT", "repository": { diff --git a/packages/mcp-tasks/src/version.ts b/packages/mcp-tasks/src/version.ts index a05375c..5df3807 100644 --- a/packages/mcp-tasks/src/version.ts +++ b/packages/mcp-tasks/src/version.ts @@ -1,2 +1,2 @@ // Generated by scripts/sync-version.mjs (run by `pnpm ci:version`) — do not edit by hand. -export const VERSION = "0.1.0"; +export const VERSION = "0.0.0"; From f89b7209ad55aa41195a14b29115c0408baae573 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Wed, 7 Oct 2026 12:13:02 +0000 Subject: [PATCH 14/33] docs(mcp-tasks): register handlers at module load in the usage example The execute route looks a handler up by name, so it only knows handlers that registerTask has run for in that process. The demo already calls createServer() at module scope; the README example did not, so a cold execute instance would fail with "No task handler registered". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd --- packages/mcp-tasks/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md index 26b4ad9..5b2c4c2 100644 --- a/packages/mcp-tasks/README.md +++ b/packages/mcp-tasks/README.md @@ -43,6 +43,10 @@ export function createServer() { return server; } + +// Registers the handler in every process, including an /api/execute instance that never serves an +// MCP request: the execute route finds a task's handler by the name stored on the task. +createServer(); ``` Then two routes — the MCP endpoint, which is the SDK's own handler unchanged, and the one the work From 7356a96ba20c0c3037770757c46fae4e7551ea7a Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Wed, 7 Oct 2026 13:31:09 +0000 Subject: [PATCH 15/33] DX-3022: rename to @upstash/mcp-toolkit and add MCP Events The package becomes @upstash/mcp-toolkit with two entry points: - /tasks (and /tasks/upstash): the long-running tools, unchanged apart from a new onSettle hook that fires once per finished task. - /events (and /events/upstash): MCP Events with webhook delivery, as ChatGPT ships it. Typed define()/emit(), events/list|subscribe| unsubscribe, signed verification challenge, deterministic ids, expiry/refresh, SSRF checks, secrets encrypted at rest, Redis subscriptions and QStash-retried Standard Webhooks deliveries. taskFinishedEvent() bridges the two. The demo adds a task.finished event and a local receiver; the smoke test checks a signed delivery end to end through the QStash dev server. Lua scripts now carry allow-key-locking. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd --- .changeset/mcp-tasks-initial.md | 18 - .changeset/mcp-toolkit-initial.md | 23 + CLAUDE.md | 31 +- README.md | 14 +- .../.env.example | 0 .../.gitignore | 0 .../README.md | 9 +- .../mcp-toolkit-demo/app/api/events/route.ts | 10 + .../app/api/execute-workflow/route.ts | 0 .../app/api/execute/route.ts | 0 .../app/api/mcp-workflow/route.ts | 0 .../app/api/mcp/route.ts | 0 .../app/api/receiver/route.ts | 42 ++ .../app/globals.css | 0 .../app/layout.tsx | 0 examples/mcp-toolkit-demo/app/lib/events.ts | 23 + .../app/lib/mcp-client.ts | 2 +- .../app/lib/qstash-server.ts | 11 +- .../app/lib/workflow-server.ts | 6 +- .../app/page.tsx | 0 .../next-env.d.ts | 0 .../next.config.ts | 0 .../package.json | 4 +- .../scripts/smoke.mjs | 39 ++ .../tsconfig.json | 0 packages/mcp-tasks/README.md | 461 ------------ packages/mcp-tasks/tsup.config.ts | 13 - packages/mcp-toolkit/README.md | 658 ++++++++++++++++++ .../{mcp-tasks => mcp-toolkit}/package.json | 33 +- .../mcp-toolkit/src/events/backends/memory.ts | 74 ++ .../src/events/backends/qstash.test.ts | 166 +++++ .../mcp-toolkit/src/events/backends/qstash.ts | 259 +++++++ packages/mcp-toolkit/src/events/core.test.ts | 427 ++++++++++++ packages/mcp-toolkit/src/events/core.ts | 540 ++++++++++++++ packages/mcp-toolkit/src/events/index.ts | 48 ++ packages/mcp-toolkit/src/events/tasks.ts | 77 ++ packages/mcp-toolkit/src/events/types.ts | 108 +++ packages/mcp-toolkit/src/events/upstash.ts | 12 + packages/mcp-toolkit/src/events/webhooks.ts | 223 ++++++ .../src/tasks}/backends/memory.ts | 0 .../src/tasks}/backends/qstash.test.ts | 2 +- .../src/tasks}/backends/qstash.ts | 6 +- .../src/tasks}/backends/workflow.test.ts | 0 .../src/tasks}/backends/workflow.ts | 0 .../src/tasks}/core.test.ts | 2 +- .../src => mcp-toolkit/src/tasks}/core.ts | 57 +- .../src => mcp-toolkit/src/tasks}/index.ts | 8 +- .../src => mcp-toolkit/src/tasks}/types.ts | 0 .../src => mcp-toolkit/src/tasks}/upstash.ts | 0 .../src/telemetry.ts | 4 +- .../src/test-support.ts | 2 +- .../{mcp-tasks => mcp-toolkit}/src/version.ts | 0 .../{mcp-tasks => mcp-toolkit}/tsconfig.json | 0 packages/mcp-toolkit/tsup.config.ts | 15 + pnpm-lock.yaml | 8 +- scripts/sync-version.mjs | 2 +- 56 files changed, 2872 insertions(+), 565 deletions(-) delete mode 100644 .changeset/mcp-tasks-initial.md create mode 100644 .changeset/mcp-toolkit-initial.md rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/.env.example (100%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/.gitignore (100%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/README.md (89%) create mode 100644 examples/mcp-toolkit-demo/app/api/events/route.ts rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/app/api/execute-workflow/route.ts (100%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/app/api/execute/route.ts (100%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/app/api/mcp-workflow/route.ts (100%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/app/api/mcp/route.ts (100%) create mode 100644 examples/mcp-toolkit-demo/app/api/receiver/route.ts rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/app/globals.css (100%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/app/layout.tsx (100%) create mode 100644 examples/mcp-toolkit-demo/app/lib/events.ts rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/app/lib/mcp-client.ts (98%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/app/lib/qstash-server.ts (86%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/app/lib/workflow-server.ts (94%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/app/page.tsx (100%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/next-env.d.ts (100%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/next.config.ts (100%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/package.json (89%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/scripts/smoke.mjs (67%) rename examples/{mcp-tasks-demo => mcp-toolkit-demo}/tsconfig.json (100%) delete mode 100644 packages/mcp-tasks/README.md delete mode 100644 packages/mcp-tasks/tsup.config.ts create mode 100644 packages/mcp-toolkit/README.md rename packages/{mcp-tasks => mcp-toolkit}/package.json (64%) create mode 100644 packages/mcp-toolkit/src/events/backends/memory.ts create mode 100644 packages/mcp-toolkit/src/events/backends/qstash.test.ts create mode 100644 packages/mcp-toolkit/src/events/backends/qstash.ts create mode 100644 packages/mcp-toolkit/src/events/core.test.ts create mode 100644 packages/mcp-toolkit/src/events/core.ts create mode 100644 packages/mcp-toolkit/src/events/index.ts create mode 100644 packages/mcp-toolkit/src/events/tasks.ts create mode 100644 packages/mcp-toolkit/src/events/types.ts create mode 100644 packages/mcp-toolkit/src/events/upstash.ts create mode 100644 packages/mcp-toolkit/src/events/webhooks.ts rename packages/{mcp-tasks/src => mcp-toolkit/src/tasks}/backends/memory.ts (100%) rename packages/{mcp-tasks/src => mcp-toolkit/src/tasks}/backends/qstash.test.ts (99%) rename packages/{mcp-tasks/src => mcp-toolkit/src/tasks}/backends/qstash.ts (99%) rename packages/{mcp-tasks/src => mcp-toolkit/src/tasks}/backends/workflow.test.ts (100%) rename packages/{mcp-tasks/src => mcp-toolkit/src/tasks}/backends/workflow.ts (100%) rename packages/{mcp-tasks/src => mcp-toolkit/src/tasks}/core.test.ts (99%) rename packages/{mcp-tasks/src => mcp-toolkit/src/tasks}/core.ts (92%) rename packages/{mcp-tasks/src => mcp-toolkit/src/tasks}/index.ts (69%) rename packages/{mcp-tasks/src => mcp-toolkit/src/tasks}/types.ts (100%) rename packages/{mcp-tasks/src => mcp-toolkit/src/tasks}/upstash.ts (100%) rename packages/{mcp-tasks => mcp-toolkit}/src/telemetry.ts (96%) rename packages/{mcp-tasks => mcp-toolkit}/src/test-support.ts (95%) rename packages/{mcp-tasks => mcp-toolkit}/src/version.ts (100%) rename packages/{mcp-tasks => mcp-toolkit}/tsconfig.json (100%) create mode 100644 packages/mcp-toolkit/tsup.config.ts diff --git a/.changeset/mcp-tasks-initial.md b/.changeset/mcp-tasks-initial.md deleted file mode 100644 index 32337fd..0000000 --- a/.changeset/mcp-tasks-initial.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@upstash/mcp-tasks": minor ---- - -Add `@upstash/mcp-tasks`: durable long-running tools for MCP servers on the official TypeScript SDK. - -A task tool answers immediately with a task id; the model polls the shared `task_status` tool for -progress and the result, and can stop the task with `task_cancel`. They are ordinary MCP tools, so -they work in every client today, through `createMcpHandler` or any transport. - -The task record lives in a `TaskStore` and the work runs behind a `TaskDispatcher`. -`@upstash/mcp-tasks/upstash` provides `RedisTaskStore` (Upstash Redis), `QStashDispatcher` (one -durable delivery per task, retried with backoff) and `WorkflowDispatcher` (one invocation per step, -for work that outlives a function). The dispatcher owns its delivery endpoint: -`export const POST = tasks.createExecuteHandler()`. - -`principal` scopes tasks to their caller, and `idempotencyKey` turns an agent's retried start into a -read of the existing task. diff --git a/.changeset/mcp-toolkit-initial.md b/.changeset/mcp-toolkit-initial.md new file mode 100644 index 0000000..83b982a --- /dev/null +++ b/.changeset/mcp-toolkit-initial.md @@ -0,0 +1,23 @@ +--- +"@upstash/mcp-toolkit": minor +--- + +Add `@upstash/mcp-toolkit`: durable building blocks for MCP servers on the official TypeScript SDK. + +`@upstash/mcp-toolkit/tasks` — long-running tools. A task tool answers immediately with a task id; +the model polls the shared `task_status` tool for progress and the result, and can stop the task +with `task_cancel`. They are ordinary MCP tools, so they work in every client today. The record +lives in a `TaskStore` and the work runs behind a `TaskDispatcher`; `/tasks/upstash` provides +`RedisTaskStore`, `QStashDispatcher` and `WorkflowDispatcher`, and the dispatcher owns its delivery +endpoint (`export const POST = tasks.createExecuteHandler()`). `principal` scopes tasks to their +caller, `idempotencyKey` turns a retried start into a read of the existing task, and `onSettle` +fires once per finished task. + +`@upstash/mcp-toolkit/events` — MCP Events with webhook delivery, as ChatGPT ships it. Typed +`events.define(...)` handles with `emit(payload)`, `events/list`, `events/subscribe` and +`events/unsubscribe` registered on your server, a signed verification challenge, deterministic +subscription ids, expiry and refresh, SSRF checks on callback URLs, and signing secrets encrypted +at rest. `/events/upstash` provides `RedisSubscriptionStore` and `QStashDelivery`, which signs each +attempt with Standard Webhooks and lets QStash retry failures (`export const POST = +events.createDeliveryHandler()`). `taskFinishedEvent` bridges the two, so an event-capable host can +stop polling. diff --git a/CLAUDE.md b/CLAUDE.md index 617818b..bf9cf63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,10 +19,10 @@ 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-tanstack-ai` (`packages/tanstack-ai`) | TanStack AI backends: persistence stores, `StreamDurability`, `LockStore`, `MemoryAdapter`, middlewares, search tools. | | `@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`) | Durable **long-running MCP tools** (start + `task_status` + `task_cancel`) 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. | +| `@upstash/mcp-toolkit` (`packages/mcp-toolkit`) | Durable building blocks for the official `@modelcontextprotocol/server` v2: `/tasks` (**long-running MCP tools** — start + `task_status` + `task_cancel`) and `/events` (**MCP Events**, webhook delivery). Each has a `/upstash` entry point. Renamed from `@upstash/mcp-tasks` before its first release. **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), -`eve-extension-demo` (a minimal eve scaffold that mounts the extension), and `mcp-tasks-demo` +`eve-extension-demo` (a minimal eve scaffold that mounts the extension), and `mcp-toolkit-demo` (Next.js; the MCP server plus a browser client that shows the JSON-RPC wire log). `langchain` was **removed** — don't reintroduce it. A first `tanstack-ai` adapter was removed in June 2026 (it only wrapped memory + a model cache); the current `packages/tanstack-ai` is a different package that @@ -436,8 +436,8 @@ implements TanStack AI's own backend contracts (see its section below) — keep variable-length and test `if KEYS[n]` in the script. - Scripts that need a key they can only learn by reading (the artifact store's old run/thread index) read it first, declare it, and compare-and-swap inside the script, retrying on a mismatch. -- Current scripts: `RedisLock` acquire/release/extend (tanstack-ai), eve `redisDocuments()` CAS, and the - tanstack-ai persistence/generation stores. Upstash tolerates a leading newline before the shebang, +- Current scripts: `RedisLock` acquire/release/extend (tanstack-ai), eve `redisDocuments()` CAS, the + tanstack-ai persistence/generation stores, and mcp-toolkit's task update/settle and subscription put. Upstash tolerates a leading newline before the shebang, standard Redis does not — keep it on line 1. ## API conventions @@ -678,7 +678,10 @@ implements TanStack AI's own backend contracts (see its section below) — keep `$and/$or/$must/$should/$mustNot`. Aggregations: `$terms`, `$stats`, `$sum`, `$avg`, `$min`, `$max`, `$count`, `$histogram`, `$percentiles`, `$cardinality`. -## MCP Tasks facts (`packages/mcp-tasks`) — IMPORTANT +## MCP toolkit facts (`packages/mcp-toolkit`) — IMPORTANT +- **Layout:** `src/tasks/` and `src/events/`, each with `index.ts` (core, storage-agnostic) and + `upstash.ts` (Redis/QStash/Workflow backends), built to `dist/{tasks,events}/{index,upstash}.js`. + There is no root export. Shared: `src/telemetry.ts` (tag `@upstash/mcp-toolkit`), `src/version.ts`. - **Tools mode, not the Tasks extension (since 2026-10).** A task tool answers with an ordinary tool result (`structuredContent` = the task object, plus a text line telling the model to poll); two shared tools, `task_status` and `task_cancel`, are registered once per server. No `tasks/*` @@ -739,8 +742,22 @@ implements TanStack AI's own backend contracts (see its section below) — keep `.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 `tools/call`s; - `src/upstash.test.ts` hits real Redis. Both run under the root vitest config. +- Tests: `src/{tasks,events}/core.test.ts` drive a real `McpServer` through `createMcpHandler` over + genuine JSON-RPC; `src/*/backends/qstash.test.ts` hit real Redis. All run under the root vitest + config. The demo's `pnpm smoke` is the end-to-end check (QStash dev server + both servers + the + `task.finished` webhook into the demo's receiver). +- **Events facts.** Wire format follows ChatGPT's MCP Events (webhook only): `events/list|subscribe| + unsubscribe` registered via `server.server.setRequestHandler(method, { params }, handler)` and + `registerCapabilities({ events: {} } as never)` (the SDK has no events types). Subscription id = + `sub_` + sha256(canonical `[owner, url, event, args]`). Matching = exact canonical args: emit + enumerates every subset of its args (cap 8 keys) and the store looks those keys up, so a + subscription to `{}` gets everything. Secrets are AES-256-GCM sealed under `secretKey` + (`MCP_EVENTS_SECRET_KEY`); a refresh with the same secret skips the challenge. Callback errors are + `ProtocolError(-32015, msg, { reason })`; bad params are `-32602` with a `reason`. + **QStash `deduplicationId` cannot contain `:`** (dev server answers 400) — ids are + `${eventId}_${subscriptionId}` sanitized; this was caught only by the e2e smoke. + The task bridge is `createTaskLayer({ onSettle })`, called only by the write that performed the + terminal transition; its errors are logged and swallowed because the task is already settled. - **Local dev needs the QStash dev server** (`npx @upstash/qstash-cli dev`) — it prints deterministic creds. `APP_URL` must be reachable *from QStash*. diff --git a/README.md b/README.md index 5d5b1c5..429ab5f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ are powered by [Upstash Redis Search](https://upstash.com/docs/redis/search/intr | [`@upstash/agentkit-eve`](./packages/eve) | Adapter for the Vercel Eve framework. | | [`@upstash/agentkit-tanstack-ai`](./packages/tanstack-ai) | Production backends for [TanStack AI](https://tanstack.com/ai) — chat persistence, resumable streams, distributed locks, memory, tool caching, rate limiting and search tools. | | [`@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) | Durable long-running tools for MCP servers on the official TypeScript SDK: a tool answers with a task id, the model polls `task_status`, the record lives in Redis, and the work runs through QStash or Workflow. Works in every client today. | +| [`@upstash/mcp-toolkit`](./packages/mcp-toolkit) | Durable building blocks for MCP servers on the official TypeScript SDK. `/tasks`: long-running tools that answer with a task id the model polls, with the record in Redis and the work on QStash or Workflow. `/events`: MCP Events, with subscriptions in Redis and signed webhook deliveries retried by QStash. | ## Core features @@ -34,9 +34,10 @@ are powered by [Upstash Redis Search](https://upstash.com/docs/redis/search/intr - **Code sandbox** (Eve only) — an [Upstash Box](https://github.com/upstash/box) sandbox provider for Eve's `defineSandbox` (`UpstashSandbox`, eve ≥ 0.65). - **Tool-call cache** — memoize deterministic tool results keyed by arguments. -- **Durable MCP tasks** (`@upstash/mcp-tasks`) — a long-running MCP tool returns a task id - instead of blocking, and the model polls `task_status`; the record lives in Redis and the work runs - through QStash or Workflow, so it survives the process that accepted the call. +- **Durable MCP tasks and events** (`@upstash/mcp-toolkit`) — a long-running MCP tool returns a task + id instead of blocking, and the model polls `task_status`; the record lives in Redis and the work + runs through QStash or Workflow, so it survives the process that accepted the call. MCP Events let + a host subscribe and get a signed webhook when something happens, including when a task finishes. ## Examples @@ -45,8 +46,9 @@ Runnable demos (real Upstash Redis + a mock/real model) live in [`examples/`](./ [`eve-extension-demo`](./examples/eve-extension-demo) (an eve agent that mounts `@upstash/agentkit-eve-extension`), [`tanstack-ai-demo`](./examples/tanstack-ai-demo) (a TanStack AI chat whose persistence, resumable stream and memory work across server instances, with -a two-instance E2E suite), and [`mcp-tasks-demo`](./examples/mcp-tasks-demo) (an MCP server whose -long-running tool returns a task id the model polls, with the client's wire log on screen). +a two-instance E2E suite), and [`mcp-toolkit-demo`](./examples/mcp-toolkit-demo) (an MCP server whose +long-running tool returns a task id the model polls, plus a `task.finished` event, with the client's +wire log on screen). ## Development diff --git a/examples/mcp-tasks-demo/.env.example b/examples/mcp-toolkit-demo/.env.example similarity index 100% rename from examples/mcp-tasks-demo/.env.example rename to examples/mcp-toolkit-demo/.env.example diff --git a/examples/mcp-tasks-demo/.gitignore b/examples/mcp-toolkit-demo/.gitignore similarity index 100% rename from examples/mcp-tasks-demo/.gitignore rename to examples/mcp-toolkit-demo/.gitignore diff --git a/examples/mcp-tasks-demo/README.md b/examples/mcp-toolkit-demo/README.md similarity index 89% rename from examples/mcp-tasks-demo/README.md rename to examples/mcp-toolkit-demo/README.md index 168416f..e82bbdb 100644 --- a/examples/mcp-tasks-demo/README.md +++ b/examples/mcp-toolkit-demo/README.md @@ -1,6 +1,6 @@ -# MCP Tasks demo +# MCP toolkit demo -A Next.js app showing `@upstash/mcp-tasks` end to end: an MCP tool that answers with a task handle +A Next.js app showing `@upstash/mcp-toolkit` 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 (or Upstash Workflow) so it survives the process that accepted the call. @@ -16,6 +16,9 @@ no capabilities at all. It speaks raw stateless JSON-RPC and shows every frame i | `app/lib/workflow-server.ts` | Server two: the same tool on Upstash Workflow, one invocation per step | | `app/api/mcp/route.ts`, `app/api/mcp-workflow/route.ts` | The MCP endpoints — the SDK's own `createMcpHandler`, unchanged | | `app/api/execute/route.ts`, `app/api/execute-workflow/route.ts` | Where the work is delivered. One line each: the dispatcher owns the endpoint | +| `app/lib/events.ts` | MCP Events: Redis subscriptions, QStash delivery, and the `task.finished` event | +| `app/api/events/route.ts` | Where QStash delivers each event. One line: the delivery owns the endpoint | +| `app/api/receiver/route.ts` | A stand-in for the host's webhook receiver, so events can be seen without a public URL | | `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 | @@ -41,7 +44,7 @@ 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, unknown task id +pnpm smoke # happy path, cancel mid-flight, unknown task id, task.finished webhook MCP_PATH=/api/mcp-workflow pnpm smoke # the same against the Workflow server ``` diff --git a/examples/mcp-toolkit-demo/app/api/events/route.ts b/examples/mcp-toolkit-demo/app/api/events/route.ts new file mode 100644 index 0000000..297d15b --- /dev/null +++ b/examples/mcp-toolkit-demo/app/api/events/route.ts @@ -0,0 +1,10 @@ +/** + * Where QStash delivers each event. The handler verifies QStash's signature, signs the envelope + * with the subscriber's secret, POSTs it, and answers 500 when the host's callback failed so + * QStash retries with backoff. + */ +import { events } from "../../lib/events"; + +export const dynamic = "force-dynamic"; + +export const POST = events.createDeliveryHandler(); diff --git a/examples/mcp-tasks-demo/app/api/execute-workflow/route.ts b/examples/mcp-toolkit-demo/app/api/execute-workflow/route.ts similarity index 100% rename from examples/mcp-tasks-demo/app/api/execute-workflow/route.ts rename to examples/mcp-toolkit-demo/app/api/execute-workflow/route.ts diff --git a/examples/mcp-tasks-demo/app/api/execute/route.ts b/examples/mcp-toolkit-demo/app/api/execute/route.ts similarity index 100% rename from examples/mcp-tasks-demo/app/api/execute/route.ts rename to examples/mcp-toolkit-demo/app/api/execute/route.ts diff --git a/examples/mcp-tasks-demo/app/api/mcp-workflow/route.ts b/examples/mcp-toolkit-demo/app/api/mcp-workflow/route.ts similarity index 100% rename from examples/mcp-tasks-demo/app/api/mcp-workflow/route.ts rename to examples/mcp-toolkit-demo/app/api/mcp-workflow/route.ts diff --git a/examples/mcp-tasks-demo/app/api/mcp/route.ts b/examples/mcp-toolkit-demo/app/api/mcp/route.ts similarity index 100% rename from examples/mcp-tasks-demo/app/api/mcp/route.ts rename to examples/mcp-toolkit-demo/app/api/mcp/route.ts diff --git a/examples/mcp-toolkit-demo/app/api/receiver/route.ts b/examples/mcp-toolkit-demo/app/api/receiver/route.ts new file mode 100644 index 0000000..8376adb --- /dev/null +++ b/examples/mcp-toolkit-demo/app/api/receiver/route.ts @@ -0,0 +1,42 @@ +/** + * A stand-in for the host's callback (ChatGPT's, in production), so the demo can show a delivery + * end to end without a public URL. + * + * - `PUT ?id=…` with `{ secret }` registers a receiver and its signing secret. + * - `POST ?id=…` is the callback: it checks the Standard Webhooks signature, answers the + * verification challenge, and records every event. + * - `GET ?id=…` returns what it received. + * + * State is in process memory: fine for `next dev`, not for anything real. + */ +import { verifyWebhook } from "@upstash/mcp-toolkit/events"; + +export const dynamic = "force-dynamic"; + +type Receiver = { secret: string; received: { valid: boolean; body: unknown }[] }; +const receivers: Map = ((globalThis as { __receivers?: Map }) + .__receivers ??= new Map()); + +const idOf = (request: Request) => new URL(request.url).searchParams.get("id") ?? ""; + +export async function PUT(request: Request): Promise { + const { secret } = (await request.json()) as { secret: string }; + receivers.set(idOf(request), { secret, received: [] }); + return new Response(null, { status: 204 }); +} + +export async function POST(request: Request): Promise { + const receiver = receivers.get(idOf(request)); + if (!receiver) return new Response("unknown receiver", { status: 410 }); + const raw = await request.text(); + const valid = await verifyWebhook(receiver.secret, request.headers, raw); + const body = JSON.parse(raw) as { type?: string; challenge?: string }; + receiver.received.push({ valid, body }); + if (!valid) return new Response("bad signature", { status: 401 }); + if (body.type === "verification") return Response.json({ challenge: body.challenge }); + return new Response(null, { status: 204 }); +} + +export async function GET(request: Request): Promise { + return Response.json(receivers.get(idOf(request))?.received ?? []); +} diff --git a/examples/mcp-tasks-demo/app/globals.css b/examples/mcp-toolkit-demo/app/globals.css similarity index 100% rename from examples/mcp-tasks-demo/app/globals.css rename to examples/mcp-toolkit-demo/app/globals.css diff --git a/examples/mcp-tasks-demo/app/layout.tsx b/examples/mcp-toolkit-demo/app/layout.tsx similarity index 100% rename from examples/mcp-tasks-demo/app/layout.tsx rename to examples/mcp-toolkit-demo/app/layout.tsx diff --git a/examples/mcp-toolkit-demo/app/lib/events.ts b/examples/mcp-toolkit-demo/app/lib/events.ts new file mode 100644 index 0000000..68b6dbb --- /dev/null +++ b/examples/mcp-toolkit-demo/app/lib/events.ts @@ -0,0 +1,23 @@ +/** + * MCP Events for the QStash server: one `task.finished` event, so a host that supports events + * (ChatGPT today) gets a signed webhook when a task settles instead of polling `task_status`. + * + * Subscriptions live in Redis with their signing secrets encrypted; each delivery is a QStash + * message to `/api/events`, which signs and POSTs it to the host and lets QStash retry failures. + */ +import { createEventLayer, taskFinishedEvent } from "@upstash/mcp-toolkit/events"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events/upstash"; + +const APP_URL = process.env.APP_URL ?? "http://127.0.0.1:3000"; + +export const events = createEventLayer({ + store: new RedisSubscriptionStore({ prefix: "mcp:events:" }), + delivery: new QStashDelivery({ url: `${APP_URL}/api/events` }), + // Generate a real one with `openssl rand -base64 32`; the fallback only exists so the demo boots. + secretKey: process.env.MCP_EVENTS_SECRET_KEY ?? "mcp-toolkit-demo-only-secret-key", + // The demo's own receiver runs on localhost. Never set this in production: it disables the + // checks that stop a subscriber from pointing your server at internal addresses. + allowInsecureCallbacks: /^http:\/\/(127\.0\.0\.1|localhost)/.test(APP_URL), +}); + +export const taskFinished = taskFinishedEvent(events); diff --git a/examples/mcp-tasks-demo/app/lib/mcp-client.ts b/examples/mcp-toolkit-demo/app/lib/mcp-client.ts similarity index 98% rename from examples/mcp-tasks-demo/app/lib/mcp-client.ts rename to examples/mcp-toolkit-demo/app/lib/mcp-client.ts index 19baf5e..671474c 100644 --- a/examples/mcp-tasks-demo/app/lib/mcp-client.ts +++ b/examples/mcp-toolkit-demo/app/lib/mcp-client.ts @@ -77,7 +77,7 @@ export async function rpc>( ...params, _meta: { "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, - "io.modelcontextprotocol/clientInfo": { name: "mcp-tasks-demo", version: "0.1.0" }, + "io.modelcontextprotocol/clientInfo": { name: "mcp-toolkit-demo", version: "0.1.0" }, "io.modelcontextprotocol/clientCapabilities": {}, }, }, diff --git a/examples/mcp-tasks-demo/app/lib/qstash-server.ts b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts similarity index 86% rename from examples/mcp-tasks-demo/app/lib/qstash-server.ts rename to examples/mcp-toolkit-demo/app/lib/qstash-server.ts index 0a38f36..b2248fc 100644 --- a/examples/mcp-tasks-demo/app/lib/qstash-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts @@ -8,9 +8,10 @@ * Compare with `workflow-server.ts`, which runs the same-looking tool with no time limit at all. */ import { McpServer } from "@modelcontextprotocol/server"; -import { createTaskLayer } from "@upstash/mcp-tasks"; -import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; +import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; import * as z from "zod"; +import { events, taskFinished } from "./events"; /** 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`; @@ -27,6 +28,8 @@ export const tasks = createTaskLayer({ store: new RedisTaskStore({ prefix: "mcp:task:qstash:" }), dispatcher, defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, + // Fires the `task.finished` event once per task, so event-capable hosts can skip polling. + onSettle: taskFinished.onSettle, }); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -34,7 +37,9 @@ 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" }); + const server = new McpServer({ name: "mcp-toolkit-demo-qstash", version: "0.1.0" }); + // events/list, events/subscribe, events/unsubscribe on the same endpoint as the tools. + events.register(server); tasks.registerTask( server, diff --git a/examples/mcp-tasks-demo/app/lib/workflow-server.ts b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts similarity index 94% rename from examples/mcp-tasks-demo/app/lib/workflow-server.ts rename to examples/mcp-toolkit-demo/app/lib/workflow-server.ts index fb7ee59..5f3f645 100644 --- a/examples/mcp-tasks-demo/app/lib/workflow-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts @@ -10,8 +10,8 @@ * `task.sleep(...)`, `task.call(...)` (the engine's) sit on one object. */ import { McpServer } from "@modelcontextprotocol/server"; -import { createTaskLayer } from "@upstash/mcp-tasks"; -import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-tasks/upstash"; +import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; import type { WorkflowContext } from "@upstash/workflow"; import * as z from "zod"; @@ -34,7 +34,7 @@ export const tasks = createTaskLayer({ const STEPS = 4; export function createServer(): McpServer { - const server = new McpServer({ name: "mcp-tasks-demo-workflow", version: "0.1.0" }); + const server = new McpServer({ name: "mcp-toolkit-demo-workflow", version: "0.1.0" }); tasks.registerTask( server, diff --git a/examples/mcp-tasks-demo/app/page.tsx b/examples/mcp-toolkit-demo/app/page.tsx similarity index 100% rename from examples/mcp-tasks-demo/app/page.tsx rename to examples/mcp-toolkit-demo/app/page.tsx diff --git a/examples/mcp-tasks-demo/next-env.d.ts b/examples/mcp-toolkit-demo/next-env.d.ts similarity index 100% rename from examples/mcp-tasks-demo/next-env.d.ts rename to examples/mcp-toolkit-demo/next-env.d.ts diff --git a/examples/mcp-tasks-demo/next.config.ts b/examples/mcp-toolkit-demo/next.config.ts similarity index 100% rename from examples/mcp-tasks-demo/next.config.ts rename to examples/mcp-toolkit-demo/next.config.ts diff --git a/examples/mcp-tasks-demo/package.json b/examples/mcp-toolkit-demo/package.json similarity index 89% rename from examples/mcp-tasks-demo/package.json rename to examples/mcp-toolkit-demo/package.json index 6a4e285..adae453 100644 --- a/examples/mcp-tasks-demo/package.json +++ b/examples/mcp-toolkit-demo/package.json @@ -1,5 +1,5 @@ { - "name": "mcp-tasks-demo", + "name": "mcp-toolkit-demo", "version": "0.1.0", "private": true, "scripts": { @@ -11,7 +11,7 @@ }, "dependencies": { "@modelcontextprotocol/server": "^2.0.0", - "@upstash/mcp-tasks": "workspace:*", + "@upstash/mcp-toolkit": "workspace:*", "@upstash/qstash": "^2.11.3", "@upstash/redis": "^1.38.0", "next": "16.2.9", diff --git a/examples/mcp-tasks-demo/scripts/smoke.mjs b/examples/mcp-toolkit-demo/scripts/smoke.mjs similarity index 67% rename from examples/mcp-tasks-demo/scripts/smoke.mjs rename to examples/mcp-toolkit-demo/scripts/smoke.mjs index 32b44da..a12a0d6 100644 --- a/examples/mcp-tasks-demo/scripts/smoke.mjs +++ b/examples/mcp-toolkit-demo/scripts/smoke.mjs @@ -88,4 +88,43 @@ const unknown = await call("task_status", { taskId: "does-not-exist" }); console.log("unknown", JSON.stringify(unknown)); if (!unknown.isError) throw new Error("expected a tool error"); +if (ENDPOINT === "/api/mcp") { + console.log("\n== 4. task.finished event =="); + const { randomBytes } = await import("node:crypto"); + const listed = (await rpc("events/list")).events.map(e => e.name); + console.log("events", listed.join(", ")); + if (!listed.includes("task.finished")) throw new Error("missing task.finished event"); + + const receiverId = randomBytes(6).toString("hex"); + const receiverUrl = `${BASE}/api/receiver?id=${receiverId}`; + const secret = `whsec_${randomBytes(32).toString("base64")}`; + await fetch(receiverUrl, { method: "PUT", body: JSON.stringify({ secret }) }); + const subscription = await rpc("events/subscribe", { + name: "task.finished", + arguments: {}, + delivery: { mode: "webhook", url: receiverUrl, secret }, + ttlMs: 600_000, + }); + console.log("subscribed", JSON.stringify(subscription)); + + const third = await call("generate_report", { topic: "events" }); + const thirdId = third.structuredContent.taskId; + let delivery; + for (let i = 0; i < 30 && !delivery; i++) { + await sleep(1500); + const received = await (await fetch(receiverUrl)).json(); + delivery = received.find(r => r.body.name === "task.finished" && r.body.data?.taskId === thirdId); + } + console.log("delivery", JSON.stringify(delivery)); + if (!delivery) throw new Error("no task.finished delivery"); + if (!delivery.valid) throw new Error("delivery signature did not verify"); + if (delivery.body.data.status !== "completed") throw new Error("expected a completed task"); + + await rpc("events/unsubscribe", { + name: "task.finished", + arguments: {}, + delivery: { mode: "webhook", url: receiverUrl }, + }); +} + console.log("\nALL E2E CHECKS PASSED"); diff --git a/examples/mcp-tasks-demo/tsconfig.json b/examples/mcp-toolkit-demo/tsconfig.json similarity index 100% rename from examples/mcp-tasks-demo/tsconfig.json rename to examples/mcp-toolkit-demo/tsconfig.json diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md deleted file mode 100644 index 5b2c4c2..0000000 --- a/packages/mcp-tasks/README.md +++ /dev/null @@ -1,461 +0,0 @@ -# @upstash/mcp-tasks - -Durable long-running tools for MCP servers on the official TypeScript SDK. - -A long-running tool answers immediately with a task id instead of blocking. The model polls a -shared `task_status` tool for progress and, once it completes, the result. The task record lives -in Upstash Redis; the work runs through QStash or Upstash Workflow, so it survives the process that -accepted the call, and it is not bound by your function's time limit or the client's tool timeout. - -Everything is served as **ordinary MCP tools**, so it works in every client today — Claude Code, -Codex, Cursor, OpenCode, ChatGPT — with no client capability required. See -[Why tools, not the Tasks extension?](#why-tools-not-the-tasks-extension) - -## Install - -```bash -npm install @upstash/mcp-tasks @modelcontextprotocol/server @upstash/redis @upstash/qstash -``` - -## Usage - -```ts -// lib/tasks.ts -import { McpServer } from "@modelcontextprotocol/server"; -import { createTaskLayer } 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" }); - - tasks.registerTask( - server, - "generate_report", - { description: "Generates a report on a topic.", inputSchema: z.object({ topic: z.string() }) }, - async ({ topic }) => ({ content: [{ type: "text", text: await writeReport(topic) }] }), - ); - - return server; -} - -// Registers the handler in every process, including an /api/execute instance that never serves an -// MCP request: the execute route finds a task's handler by the name stored on the task. -createServer(); -``` - -Then two routes — the MCP endpoint, which is the SDK's own handler unchanged, and the one the work -is delivered to: - -```ts -// app/api/mcp/route.ts -import { createMcpHandler } from "@modelcontextprotocol/server"; -import { createServer } from "../../lib/tasks"; - -const handler = createMcpHandler(() => createServer()); -export const POST = (request: Request) => handler.fetch(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. Because the layer only registers tools, it works the same with -[`mcp-handler`](https://www.npmjs.com/package/mcp-handler) or any transport. - -`tools/list` now shows three tools: - -| Tool | What it does | -| --- | --- | -| `generate_report` | Starts the task and answers with its `taskId` at once | -| `task_status` | Progress while `working`; the handler's own result once `completed` | -| `task_cancel` | Asks the task to stop; idempotent | - -`task_status` and `task_cancel` are shared by every task tool on the server. - -
-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 model sees as `statusMessage` on its next `task_status`. -Cancellation is cooperative: `task_cancel` flips the record and stops a pending delivery, but -running code only stops where it checks. - -
- -## Multi-user servers: set `principal` - -Without it, anyone holding a task id can read and cancel that task. With it, each task records its -owner, and `task_status` / `task_cancel` answer only for the caller who started it — another -caller's id reads exactly like an unknown one. - -```ts -const tasks = createTaskLayer({ - store: new RedisTaskStore(), - dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - // Receives the AuthInfo your auth middleware attached to the request. - principal: (auth) => auth?.extra?.userId as string | undefined, -}); -``` - -Key on the *user*, not `auth.clientId`: the client id identifies the OAuth app, which is often one -id shared by every user of a host like ChatGPT. - -## Retries without duplicates: `idempotencyKey` - -Agents retry tool calls, especially ones that seemed to time out. Give a task tool an -`idempotencyKey`, and a second call from the same caller with the same key returns the existing -task instead of starting another: - -```ts -tasks.registerTask(server, "generate_report", { - description: "Generates a report on a topic.", - inputSchema: z.object({ topic: z.string() }), - idempotencyKey: ({ topic }) => topic, -}, handler); -``` - -Keys are scoped by caller and tool, and last as long as the task is retained (`ttlMs`). Return -`undefined` to opt a call out. - -## What the model sees - -```jsonc -// generate_report → a handle, immediately -{ "content": [{ "type": "text", "text": "Started task 0e30…. Call task_status with taskId \"0e30…\" in about 2s to check on it." }], - "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 300000, "pollIntervalMs": 2000 } } - -// task_status → progress… -{ "content": [{ "type": "text", "text": "Task 0e30… is working: Reading source 2. Check again in about 2s." }], - "structuredContent": { "taskId": "0e30…", "status": "working", "statusMessage": "Reading source 2" } } - -// …then the handler's own content, as if the tool had run synchronously -{ "content": [{ "type": "text", "text": "Task 0e30… is completed: Completed" }, - { "type": "text", "text": "Report on coffee" }], - "structuredContent": { "taskId": "0e30…", "status": "completed", "result": { "content": [ … ] } } } -``` - -The states are `working`, `completed`, `failed` and `cancelled` (plus `input_required`, reserved); -the last three are terminal and never change again. The task object is the same shape as the -protocol's Tasks extension, so a native adapter can serve these records unchanged later. - -## 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`. - -
- -## How it fits together - -
-Who is responsible for what - -| | Owns | -| --- | --- | -| **`@upstash/mcp-tasks`** | The tools: creating the record before replying, `task_status` / `task_cancel`, ownership and idempotency, 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: starting a task - -```mermaid -sequenceDiagram - participant M as Model - participant S as mcp-tasks - participant St as TaskStore - participant D as TaskDispatcher - - M->>S: tools/call generate_report - S->>St: create(task) - Note over St: must commit before the reply —
the next poll may hit another instance - St-->>S: ok - S->>D: dispatch(taskId) - D-->>S: dispatchId - S->>St: update({ dispatchId }) - S-->>M: taskId + "call task_status" -``` - -
- -
-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->>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: task_status and task_cancel - -```mermaid -sequenceDiagram - participant M as Model - participant S as mcp-tasks - participant St as TaskStore - participant D as TaskDispatcher - - M->>S: task_status { taskId } - S->>St: get(taskId) - S->>S: owner matches? else "unknown task" - S-->>M: status, or the result once completed - - M->>S: task_cancel { taskId } - S->>St: settle(cancelled) - Note over St: refused if already terminal —
first terminal write wins - S->>D: cancel(dispatchId) - S-->>M: the cancelled task -``` - -
- -## Why tools, not the Tasks extension? - -The 2026-07-28 spec defines a Tasks extension (`io.modelcontextprotocol/tasks`): a `tools/call` -answers with `resultType: "task"`, and the client polls `tasks/get`. It is the right long-term -shape — the *client* polls, so the model spends no turns on it. But a server must never return a -task to a client that has not declared the extension, and as of October 2026 none of the clients -people actually use do: not Claude Code, Codex, Cursor or OpenCode. Of the official SDKs only Rust -and C# implement it; TypeScript and Python have it on their roadmaps. - -Plain tools trade some polling turns for working everywhere today. The model is told to poll, gets -a suggested interval, and receives the result in the same shape it would have synchronously. - -The store and dispatcher do not care which surface sits on top. When clients declare the -extension, a native adapter can answer the same records over `tasks/get` / `tasks/cancel` for those -clients, and keep the tools for everyone else. - -## 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 suggested to the model. Default 2s. | -| `principal` | `(auth) => string \| undefined` — scopes tasks to their caller. Set it on any multi-user server. | -| `toolNames` | Rename `task_status` / `task_cancel`, e.g. to namespace them. | - -**`registerTask` config** — `description`, `inputSchema`, plus optional `title`, `ttlMs`, -`pollIntervalMs`, `queuedMessage`, `completedMessage`, `idempotencyKey`. - -**`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, cancelTask, 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 the model sees | -| `isTerminal`, `TERMINAL_STATUSES`, `UnknownTaskError` | Status helpers and the store's error type | -| `DEFAULT_TOOL_NAMES`, `CallerAuth` | `{ status: "task_status", cancel: "task_cancel" }`, and what `principal` receives | -| `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. - -
- -
-Won't the model give up polling? - -Sometimes, which is why every response says what to do next in words — "call `task_status` in -about 2s" — and `task_status` tells the model not to start the task again. If it does start it -again, `idempotencyKey` turns the retry into a read of the existing task. Nothing is lost if the -model stops polling: the work finishes anyway, and the result stays readable until the task's TTL. - -
- -
-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? - -Without `principal`, effectively yes: ids are random (~122 bits for `randomUUID`), but anyone who -learns one can read *and cancel* that task. With `principal` set, an id is useless to anyone but -its owner. Idempotent task ids are a hash of caller, tool and key, so they are not guessable either. - -
- -## Not implemented - -- `input_required` — a handler asking the user something mid-task. It would be the same shape: - write the question into the record, let the handler read the answer at a step boundary. -- Listing tasks. Deliberately absent: without sessions, a list is only safe once scoped by - `principal`, and the model rarely needs it. -- A native Tasks-extension adapter and an MCP Events bridge (push a `task.completed` event instead - of polling). Both sit on the same store; they wait on client support. diff --git a/packages/mcp-tasks/tsup.config.ts b/packages/mcp-tasks/tsup.config.ts deleted file mode 100644 index 4d4a102..0000000 --- a/packages/mcp-tasks/tsup.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md new file mode 100644 index 0000000..4fff987 --- /dev/null +++ b/packages/mcp-toolkit/README.md @@ -0,0 +1,658 @@ +# @upstash/mcp-toolkit + +Durable building blocks for MCP servers on the official TypeScript SDK, backed by Upstash Redis, +QStash and Workflow. + +| Entry point | What it gives your server | +| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`@upstash/mcp-toolkit/tasks`](#tasks-long-running-tools) | **Long-running tools.** A tool answers at once with a task id; the model polls `task_status` for progress and the result. Works in every client today. | +| [`@upstash/mcp-toolkit/events`](#events-webhooks-that-wake-the-agent) | **MCP Events.** Hosts subscribe to your events and get a signed webhook when one happens, so an agent wakes up instead of polling. Works in ChatGPT today. | + +Each has a storage-agnostic core plus a `/upstash` entry point with the Redis and QStash backends, +and in-memory backends for tests. They also compose: a `task.finished` event tells an +event-capable host that a task settled, so it can skip polling. + +## Install + +```bash +npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash +``` + +`@upstash/workflow` is only needed for `WorkflowDispatcher`. + +## Tasks: long-running tools + +A long-running tool answers immediately with a task id instead of blocking. The model polls a +shared `task_status` tool for progress and, once it completes, the result. The task record lives +in Upstash Redis; the work runs through QStash or Upstash Workflow, so it survives the process that +accepted the call, and it is not bound by your function's time limit or the client's tool timeout. + +Everything is served as **ordinary MCP tools**, so it works in every client today — Claude Code, +Codex, Cursor, OpenCode, ChatGPT — with no client capability required. See +[Why tools, not the Tasks extension?](#why-tools-not-the-tasks-extension) + +### Usage + +```ts +// lib/tasks.ts +import { McpServer } from "@modelcontextprotocol/server"; +import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/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" }); + + tasks.registerTask( + server, + "generate_report", + { description: "Generates a report on a topic.", inputSchema: z.object({ topic: z.string() }) }, + async ({ topic }) => ({ content: [{ type: "text", text: await writeReport(topic) }] }), + ); + + return server; +} + +// Registers the handler in every process, including an /api/execute instance that never serves an +// MCP request: the execute route finds a task's handler by the name stored on the task. +createServer(); +``` + +Then two routes — the MCP endpoint, which is the SDK's own handler unchanged, and the one the work +is delivered to: + +```ts +// app/api/mcp/route.ts +import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createServer } from "../../lib/tasks"; + +const handler = createMcpHandler(() => createServer()); +export const POST = (request: Request) => handler.fetch(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. Because the layer only registers tools, it works the same with +[`mcp-handler`](https://www.npmjs.com/package/mcp-handler) or any transport. + +`tools/list` now shows three tools: + +| Tool | What it does | +| ----------------- | ------------------------------------------------------------------- | +| `generate_report` | Starts the task and answers with its `taskId` at once | +| `task_status` | Progress while `working`; the handler's own result once `completed` | +| `task_cancel` | Asks the task to stop; idempotent | + +`task_status` and `task_cancel` are shared by every task tool on the server. + +
+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 model sees as `statusMessage` on its next `task_status`. +Cancellation is cooperative: `task_cancel` flips the record and stops a pending delivery, but +running code only stops where it checks. + +
+ +### Multi-user servers: set `principal` + +Without it, anyone holding a task id can read and cancel that task. With it, each task records its +owner, and `task_status` / `task_cancel` answer only for the caller who started it — another +caller's id reads exactly like an unknown one. + +```ts +const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + // Receives the AuthInfo your auth middleware attached to the request. + principal: (auth) => auth?.extra?.userId as string | undefined, +}); +``` + +Key on the _user_, not `auth.clientId`: the client id identifies the OAuth app, which is often one +id shared by every user of a host like ChatGPT. + +### Retries without duplicates: `idempotencyKey` + +Agents retry tool calls, especially ones that seemed to time out. Give a task tool an +`idempotencyKey`, and a second call from the same caller with the same key returns the existing +task instead of starting another: + +```ts +tasks.registerTask( + server, + "generate_report", + { + description: "Generates a report on a topic.", + inputSchema: z.object({ topic: z.string() }), + idempotencyKey: ({ topic }) => topic, + }, + handler, +); +``` + +Keys are scoped by caller and tool, and last as long as the task is retained (`ttlMs`). Return +`undefined` to opt a call out. + +### What the model sees + +```jsonc +// generate_report → a handle, immediately +{ "content": [{ "type": "text", "text": "Started task 0e30…. Call task_status with taskId \"0e30…\" in about 2s to check on it." }], + "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 300000, "pollIntervalMs": 2000 } } + +// task_status → progress… +{ "content": [{ "type": "text", "text": "Task 0e30… is working: Reading source 2. Check again in about 2s." }], + "structuredContent": { "taskId": "0e30…", "status": "working", "statusMessage": "Reading source 2" } } + +// …then the handler's own content, as if the tool had run synchronously +{ "content": [{ "type": "text", "text": "Task 0e30… is completed: Completed" }, + { "type": "text", "text": "Report on coffee" }], + "structuredContent": { "taskId": "0e30…", "status": "completed", "result": { "content": [ … ] } } } +``` + +The states are `working`, `completed`, `failed` and `cancelled` (plus `input_required`, reserved); +the last three are terminal and never change again. The task object is the same shape as the +protocol's Tasks extension, so a native adapter can serve these records unchanged later. + +### 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-toolkit/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`. + +
+ +### How it fits together + +
+Who is responsible for what + +| | Owns | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`@upstash/mcp-toolkit/tasks`** | The tools: creating the record before replying, `task_status` / `task_cancel`, ownership and idempotency, 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: starting a task + +```mermaid +sequenceDiagram + participant M as Model + participant S as mcp-toolkit + participant St as TaskStore + participant D as TaskDispatcher + + M->>S: tools/call generate_report + S->>St: create(task) + Note over St: must commit before the reply —
the next poll may hit another instance + St-->>S: ok + S->>D: dispatch(taskId) + D-->>S: dispatchId + S->>St: update({ dispatchId }) + S-->>M: taskId + "call task_status" +``` + +
+ +
+Flow: the work running + +```mermaid +sequenceDiagram + participant D as Dispatcher (QStash/Workflow) + participant E as /api/execute + participant S as mcp-toolkit + participant H as Your handler + participant St as TaskStore + + D->>E: deliver the task (authenticated by the transport) + 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: task_status and task_cancel + +```mermaid +sequenceDiagram + participant M as Model + participant S as mcp-toolkit + participant St as TaskStore + participant D as TaskDispatcher + + M->>S: task_status { taskId } + S->>St: get(taskId) + S->>S: owner matches? else "unknown task" + S-->>M: status, or the result once completed + + M->>S: task_cancel { taskId } + S->>St: settle(cancelled) + Note over St: refused if already terminal —
first terminal write wins + S->>D: cancel(dispatchId) + S-->>M: the cancelled task +``` + +
+ +### Why tools, not the Tasks extension? + +The 2026-07-28 spec defines a Tasks extension (`io.modelcontextprotocol/tasks`): a `tools/call` +answers with `resultType: "task"`, and the client polls `tasks/get`. It is the right long-term +shape — the _client_ polls, so the model spends no turns on it. But a server must never return a +task to a client that has not declared the extension, and as of October 2026 none of the clients +people actually use do: not Claude Code, Codex, Cursor or OpenCode. Of the official SDKs only Rust +and C# implement it; TypeScript and Python have it on their roadmaps. + +Plain tools trade some polling turns for working everywhere today. The model is told to poll, gets +a suggested interval, and receives the result in the same shape it would have synchronously. + +The store and dispatcher do not care which surface sits on top. When clients declare the +extension, a native adapter can answer the same records over `tasks/get` / `tasks/cancel` for those +clients, and keep the tools for everyone else. + +## Events: webhooks that wake the agent + +[MCP Events](https://github.com/modelcontextprotocol/experimental-ext-triggers-events) is a draft +extension: the host subscribes to an event on your server and hands over a callback URL and a +signing secret; when the event happens, your server POSTs a signed envelope to that URL. ChatGPT +ships the webhook flavor ([OpenAI's guide](https://developers.openai.com/plugins/build/mcp-events)). + +This entry point implements the server side: `events/list`, `events/subscribe` and +`events/unsubscribe` on your MCP endpoint, the signed verification challenge, deterministic +subscription ids, expiry and refresh, Standard Webhooks signing, and durable retried delivery. + +### Usage + +```ts +// lib/events.ts +import { createEventLayer } from "@upstash/mcp-toolkit/events"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events/upstash"; +import * as z from "zod"; + +export const events = createEventLayer({ + store: new RedisSubscriptionStore(), + delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), + secretKey: process.env.MCP_EVENTS_SECRET_KEY, // encrypts the hosts' signing secrets at rest + principal: (auth) => auth?.extra?.userId as string | undefined, +}); + +export const commentCreated = events.define("comment.created", { + description: "A new review comment was added to a document.", + input: z.object({ documentId: z.string() }), + payload: z.object({ documentId: z.string(), commentId: z.string(), text: z.string() }), + authorize: (args, { principal }) => canRead(principal, args.documentId), +}); +``` + +Register it next to your tools, and serve the route QStash delivers to: + +```ts +// in createServer() +events.register(server); +``` + +```ts +// app/api/events/route.ts +import { events } from "../../lib/events"; + +export const POST = events.createDeliveryHandler(); +``` + +Then emit from wherever the change happens — a route, a webhook from your own app, a job: + +```ts +await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); +``` + +`emit` is typed by the `payload` schema and validates against it. It finds every live +subscription whose arguments match, and hands each one to QStash. The delivery route signs the +envelope with that subscriber's secret, POSTs it, and answers 500 when the callback failed so +QStash retries with backoff. Each attempt is signed fresh, and the event id stays the same, so the +host can drop duplicates. + +### Matching + +A subscription matches when every argument it gave equals the value emitted. Emitting +`{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of +`{ repo: "a", branch: "main" }`, and of `{}`. By default the values come from the payload fields +named in the input schema; pass `args` to set them explicitly, `owner` to deliver only to one +principal's subscriptions, and `eventId` to make a repeated emit deduplicate: + +```ts +await commentCreated.emit(payload, { args: { documentId }, owner: userId, eventId: comment.id }); +``` + +For conditions exact matching cannot express, add `match: (args, payload) => boolean` to the +definition. + +### `task.finished`: tasks that push instead of being polled + +```ts +import { createEventLayer, taskFinishedEvent } from "@upstash/mcp-toolkit/events"; + +const events = createEventLayer({ + /* … */ +}); +const taskFinished = taskFinishedEvent(events); + +const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); +``` + +A host subscribes with no arguments to hear about every task its user starts, or with `taskId` for +one. The payload carries the status and, when it fits in the 256 KiB envelope, the result. +Deliveries only go to the task owner's subscriptions. + +### What the layer checks for you + +- **The callback.** It must be `https` on a public host: `localhost`, single-label and `.internal` + names, private, loopback and link-local IPs, and credentials in the URL are refused, and + redirects are never followed. Before storing a subscription the server POSTs a signed challenge + and requires it echoed back; failures answer with `-32015` and a `reason`. Set + `allowInsecureCallbacks` for local development only. +- **The secret.** `whsec_` plus 24–64 base64 bytes, stored AES-256-GCM encrypted under + `secretKey`. A refresh with the same secret skips the challenge; a new one re-verifies. +- **Authorization.** `authorize(args, { principal, auth })` runs on every subscribe and refresh. +- **Lifetime.** The host's `ttlMs` is granted up to `defaults.maxTtlMs` (30 days); `refreshBefore` + tells it when to subscribe again. +- **Host answers.** `410` deletes the subscription, `413` drops the event, anything else retries. + +### Who can subscribe today + +As of October 2026, ChatGPT is the only widely used host that subscribes to MCP Events (webhook +mode, in Work chats). Codex supports events only for OpenAI's own connectors, and Claude Code, +Cursor and OpenCode do not subscribe yet. Poll and stream delivery modes in the draft are not +implemented here; `events/subscribe` refuses them. + +## Reference + +
+The task 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: tasks + +**`createTaskLayer`** + +| | | +| ------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `store`, `dispatcher` | Required. | +| `defaults.ttlMs` | Retention window, `null` for unlimited. Default 5 min. | +| `defaults.pollIntervalMs` | Poll interval suggested to the model. Default 2s. | +| `principal` | `(auth) => string \| undefined` — scopes tasks to their caller. Set it on any multi-user server. | +| `toolNames` | Rename `task_status` / `task_cancel`, e.g. to namespace them. | +| `onSettle` | `(task) => void` — called once when a task completes, fails or is cancelled. Wire `taskFinishedEvent` here. | + +**`registerTask` config** — `description`, `inputSchema`, plus optional `title`, `ttlMs`, +`pollIntervalMs`, `queuedMessage`, `completedMessage`, `idempotencyKey`. + +**`RedisTaskStore`** — `redis` (defaults to `Redis.fromEnv()`), `prefix`, `enableTelemetry`. + +**`QStashDispatcher`** — `url` required; `qstash`, `receiver`, `retries`, `retryDelay`, `headers`. + +**`WorkflowDispatcher`** — `url` required; `client`, `headers`, `retries`. + +
+ +
+Exports: /tasks + +| Export | What it is | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `createTaskLayer(options)` | `{ registerTask, executeTask, failTask, createExecuteHandler, getTask, cancelTask, 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 the model sees | +| `isTerminal`, `TERMINAL_STATUSES`, `UnknownTaskError` | Status helpers and the store's error type | +| `DEFAULT_TOOL_NAMES`, `CallerAuth` | `{ status: "task_status", cancel: "task_cancel" }`, and what `principal` receives | +| `MemoryTaskStore`, `InlineTaskDispatcher` | Non-durable backends for tests | +| `@upstash/mcp-toolkit/tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher` | + +
+ +
+The event interfaces + +```ts +interface SubscriptionStore { + put(subscription: Subscription): Promise; + get(id: string): Promise; + delete(id: string): Promise; + /** Every live subscription to `event` whose canonical arguments are one of `argsKeys`. */ + find(event: string, argsKeys: string[]): Promise; +} + +interface EventDelivery { + enqueue(jobs: DeliveryJob[]): Promise; + attach?(endpoints: { send(job: DeliveryJob): Promise }): void; + createDeliveryHandler?(): (request: Request) => Promise; +} +``` + +`RedisSubscriptionStore` keeps one expiring key per subscription and a sorted set per +`(event, arguments)` scored by expiry, so an emit reads only what it can match. +`MemorySubscriptionStore` + `InlineDelivery` (sends in-process, no retries) ship for tests. + +
+ +
+Options: events + +**`createEventLayer`** + +| | | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `store`, `delivery` | Required. | +| `secretKey` | Encrypts stored signing secrets. Defaults to `MCP_EVENTS_SECRET_KEY`; required. | +| `principal` | `(auth) => string \| undefined` — part of the subscription id, passed to `authorize`, used by `emit({ owner })`. | +| `defaults.ttlMs` / `defaults.maxTtlMs` | Granted lifetime when none is asked for (7 days), and the cap (30 days). | +| `allowInsecureCallbacks` | Accept `http://` and private hosts. Local development only. | +| `timeoutMs` | Per-POST timeout. Default 10s. | + +**`define` config** — `description`, `payload`, plus optional `title`, `input`, `authorize`, `match`. + +**`RedisSubscriptionStore`** — `redis`, `prefix` (default `mcp-events:`), `enableTelemetry`. + +**`QStashDelivery`** — `url` required; `qstash`, `receiver`, `retries` (default 3), `retryDelay`, `headers`. + +
+ +
+Exports: /events + +| Export | What it is | +| --------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `createEventLayer(options)` | `{ define, register, createDeliveryHandler, send, list, store, delivery }` | +| `taskFinishedEvent(events)` | `{ event, onSettle }` — the bridge from tasks | +| `SubscriptionStore`, `EventDelivery`, `Subscription`, `EventEnvelope` | The two seams and the records | +| `signWebhook`, `verifyWebhook` | Standard Webhooks signing, and verification for writing a receiver | +| `callbackUrlProblem`, `decodeSecret`, `SecretBox` | The callback, secret and encryption checks the layer uses | +| `EventPayloadTooLargeError`, `CALLBACK_ENDPOINT_ERROR`, `MAX_PAYLOAD_BYTES` | Limits and errors | +| `MemorySubscriptionStore`, `InlineDelivery` | Non-durable backends for tests | +| `@upstash/mcp-toolkit/events/upstash` | `RedisSubscriptionStore`, `QStashDelivery` | + +
+ +## 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. + +
+ +
+Won't the model give up polling? + +Sometimes, which is why every response says what to do next in words — "call `task_status` in +about 2s" — and `task_status` tells the model not to start the task again. If it does start it +again, `idempotencyKey` turns the retry into a read of the existing task. Nothing is lost if the +model stops polling: the work finishes anyway, and the result stays readable until the task's TTL. + +
+ +
+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? + +Without `principal`, effectively yes: ids are random (~122 bits for `randomUUID`), but anyone who +learns one can read _and cancel_ that task. With `principal` set, an id is useless to anyone but +its owner. Idempotent task ids are a hash of caller, tool and key, so they are not guessable either. + +
+ +## Not implemented + +- `input_required` — a handler asking the user something mid-task. It would be the same shape: + write the question into the record, let the handler read the answer at a step boundary. +- Listing tasks. Deliberately absent: without sessions, a list is only safe once scoped by + `principal`, and the model rarely needs it. +- A native Tasks-extension adapter. It sits on the same store; it waits on client support. +- Event replay (`cursor`), and the draft's poll and stream delivery modes. Subscriptions always + answer `cursor: null, truncated: false`. diff --git a/packages/mcp-tasks/package.json b/packages/mcp-toolkit/package.json similarity index 64% rename from packages/mcp-tasks/package.json rename to packages/mcp-toolkit/package.json index dbfe8a7..4418986 100644 --- a/packages/mcp-tasks/package.json +++ b/packages/mcp-toolkit/package.json @@ -1,29 +1,34 @@ { - "name": "@upstash/mcp-tasks", + "name": "@upstash/mcp-toolkit", "version": "0.0.0", - "description": "Durable long-running tools for MCP servers on the official TypeScript SDK: start, status and cancel tools over a pluggable task store and dispatcher, with Upstash Redis, QStash and Workflow backends.", + "description": "Durable building blocks for MCP servers on the official TypeScript SDK: long-running tools (tasks) and webhook events, with Upstash Redis, QStash and Workflow backends.", "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/upstash/agentkit.git", - "directory": "packages/mcp-tasks" + "directory": "packages/mcp-toolkit" }, - "homepage": "https://github.com/upstash/agentkit/tree/main/packages/mcp-tasks", + "homepage": "https://github.com/upstash/agentkit/tree/main/packages/mcp-toolkit", "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" + "./tasks": { + "types": "./dist/tasks/index.d.ts", + "import": "./dist/tasks/index.js" }, - "./upstash": { - "types": "./dist/upstash.d.ts", - "import": "./dist/upstash.js" + "./tasks/upstash": { + "types": "./dist/tasks/upstash.d.ts", + "import": "./dist/tasks/upstash.js" + }, + "./events": { + "types": "./dist/events/index.d.ts", + "import": "./dist/events/index.js" + }, + "./events/upstash": { + "types": "./dist/events/upstash.d.ts", + "import": "./dist/events/upstash.js" } }, "files": [ @@ -43,6 +48,8 @@ "mcp", "model-context-protocol", "tasks", + "events", + "webhooks", "durable", "long-running", "ai", diff --git a/packages/mcp-toolkit/src/events/backends/memory.ts b/packages/mcp-toolkit/src/events/backends/memory.ts new file mode 100644 index 0000000..9f39470 --- /dev/null +++ b/packages/mcp-toolkit/src/events/backends/memory.ts @@ -0,0 +1,74 @@ +/** + * Single-process backends, for tests and for a first local run. + * + * {@link MemorySubscriptionStore} forgets every subscription on restart, and {@link InlineDelivery} + * POSTs from the process that called `emit`, once, with no retries. Use the Upstash backends when + * a host has to be able to rely on the deliveries. + */ +import type { + DeliveryEndpoints, + DeliveryJob, + EventDelivery, + SendOutcome, + Subscription, + SubscriptionStore, +} from "../types.js"; + +/** An in-process {@link SubscriptionStore}. Not durable, not shared between instances. */ +export class MemorySubscriptionStore implements SubscriptionStore { + private readonly subscriptions = new Map(); + + async put(subscription: Subscription): Promise { + this.subscriptions.set(subscription.id, { ...subscription }); + } + + async get(id: string): Promise { + const subscription = this.subscriptions.get(id); + if (!subscription) return null; + if (subscription.expiresAt <= Date.now()) { + this.subscriptions.delete(id); + return null; + } + return { ...subscription }; + } + + async delete(id: string): Promise { + this.subscriptions.delete(id); + } + + async find(event: string, argsKeys: string[]): Promise { + const keys = new Set(argsKeys); + const now = Date.now(); + return [...this.subscriptions.values()] + .filter((sub) => sub.event === event && keys.has(sub.argsKey) && sub.expiresAt > now) + .map((sub) => ({ ...sub })); + } +} + +/** + * Sends each delivery from the calling process, once. `emit` resolves after every POST has been + * answered. A failed delivery is reported to `onOutcome` and then forgotten. + */ +export class InlineDelivery implements EventDelivery { + private endpoints: DeliveryEndpoints | undefined; + + constructor( + private readonly options: { onOutcome?: (job: DeliveryJob, outcome: SendOutcome) => void } = {}, + ) {} + + attach(endpoints: DeliveryEndpoints): void { + this.endpoints = endpoints; + } + + async enqueue(jobs: DeliveryJob[]): Promise { + const endpoints = this.endpoints; + if (!endpoints) + throw new Error("InlineDelivery is not attached — pass it to createEventLayer()."); + await Promise.all( + jobs.map(async (job) => { + const outcome = await endpoints.send(job); + this.options.onOutcome?.(job, outcome); + }), + ); + } +} diff --git a/packages/mcp-toolkit/src/events/backends/qstash.test.ts b/packages/mcp-toolkit/src/events/backends/qstash.test.ts new file mode 100644 index 0000000..b8c05c4 --- /dev/null +++ b/packages/mcp-toolkit/src/events/backends/qstash.test.ts @@ -0,0 +1,166 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { QStashDelivery, RedisSubscriptionStore } from "./qstash.js"; +import type { DeliveryJob, SendOutcome, Subscription } from "../types.js"; +import { canonicalJson } from "../core.js"; +import { cleanupKeys, hasRedisCreds, testRedis, uniquePrefix } from "../../test-support.js"; + +const makeSub = (overrides: Partial = {}): Subscription => { + const args = overrides.args ?? { repo: "upstash/agentkit" }; + return { + id: `sub_${Math.random().toString(36).slice(2, 10)}`, + event: "push", + args, + argsKey: canonicalJson(args), + url: "https://receiver.example.com/cb", + encryptedSecret: "v1.aaa.bbb", + owner: "123", + createdAt: new Date().toISOString(), + expiresAt: Date.now() + 60_000, + ...overrides, + }; +}; + +describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { + const redis = testRedis(); + const prefix = uniquePrefix("events"); + const store = new RedisSubscriptionStore({ redis, prefix }); + + afterAll(async () => { + await cleanupKeys(redis, prefix); + }); + + it("round-trips a subscription, keeping a numeric-looking owner a string", async () => { + const sub = makeSub(); + await store.put(sub); + expect(await store.get(sub.id)).toEqual(sub); + expect(await store.get("sub_missing")).toBeNull(); + }); + + it("expires the record with the subscription", async () => { + const sub = makeSub({ expiresAt: Date.now() + 30_000 }); + await store.put(sub); + const pttl = await redis.pttl(store.subKey(sub.id)); + expect(pttl).toBeGreaterThan(0); + expect(pttl).toBeLessThanOrEqual(30_000); + expect(await redis.pttl(store.indexKey(sub.event, sub.argsKey))).toBeGreaterThan(0); + }); + + it("finds subscriptions by any of several argument keys, and only live ones", async () => { + const all = makeSub({ event: "find", args: {} }); + const one = makeSub({ event: "find", args: { repo: "a" } }); + const other = makeSub({ event: "find", args: { repo: "b" } }); + for (const sub of [all, one, other]) await store.put(sub); + + const found = await store.find("find", ["{}", canonicalJson({ repo: "a" })]); + expect(found.map((s) => s.id).sort()).toEqual([all.id, one.id].sort()); + expect(await store.find("find", [])).toEqual([]); + expect(await store.find("other-event", ["{}"])).toEqual([]); + }); + + it("refreshing replaces the record and extends the index", async () => { + const sub = makeSub({ event: "refresh", expiresAt: Date.now() + 10_000 }); + await store.put(sub); + await store.put({ ...sub, expiresAt: Date.now() + 50_000 }); + expect((await store.get(sub.id))?.expiresAt).toBeGreaterThan(Date.now() + 40_000); + expect(await store.find("refresh", [sub.argsKey])).toHaveLength(1); + expect(await redis.pttl(store.indexKey(sub.event, sub.argsKey))).toBeGreaterThan(40_000); + }); + + it("deletes the record and its index entry", async () => { + const sub = makeSub({ event: "delete" }); + await store.put(sub); + await store.delete(sub.id); + expect(await store.get(sub.id)).toBeNull(); + expect(await store.find("delete", [sub.argsKey])).toEqual([]); + expect(await redis.zcard(store.indexKey(sub.event, sub.argsKey))).toBe(0); + await store.delete(sub.id); // idempotent + }); +}); + +describe("QStashDelivery", () => { + const receiver = (accept: boolean) => + ({ + verify: async () => { + if (!accept) throw new Error("bad signature"); + return true; + }, + }) as unknown as ConstructorParameters[0]["receiver"]; + + const job: DeliveryJob = { + subscriptionId: "sub_1", + envelope: { + eventId: "evt_1", + name: "push", + timestamp: "2026-10-07T00:00:00Z", + data: {}, + cursor: null, + }, + }; + + const attached = (outcome: SendOutcome, accept = true) => { + const sent: DeliveryJob[] = []; + const delivery = new QStashDelivery({ + url: "https://example.com/api/events", + receiver: receiver(accept), + }); + delivery.attach({ + send: async (j) => { + sent.push(j); + return outcome; + }, + }); + return { handler: delivery.createDeliveryHandler(), sent }; + }; + + const post = (body: unknown) => + new Request("https://internal.example/api/events", { + method: "POST", + headers: { "upstash-signature": "sig" }, + body: JSON.stringify(body), + }); + + it("acknowledges delivered, gone and dropped with 200", async () => { + for (const outcome of ["delivered", "gone", "dropped"] as const) { + const { handler, sent } = attached(outcome); + expect((await handler(post(job))).status).toBe(200); + expect(sent).toEqual([job]); + } + }); + + it("answers 500 so QStash retries a failed callback", async () => { + const { handler } = attached("retry"); + expect((await handler(post(job))).status).toBe(500); + }); + + it("rejects an unsigned or malformed delivery", async () => { + const unsigned = attached("delivered", false); + expect((await unsigned.handler(post(job))).status).toBe(401); + expect(unsigned.sent).toEqual([]); + const { handler } = attached("delivered"); + expect((await handler(post({ nope: true }))).status).toBe(400); + }); + + it("publishes one deduplicated message per subscription, in batches of 100", async () => { + const batches: Record[][] = []; + const qstash = { + batchJSON: async (messages: Record[]) => { + batches.push(messages); + return []; + }, + } as unknown as ConstructorParameters[0]["qstash"]; + const delivery = new QStashDelivery({ + url: "https://example.com/api/events", + qstash, + retries: 5, + }); + const jobs = Array.from({ length: 150 }, (_, i) => ({ ...job, subscriptionId: `sub_${i}` })); + await delivery.enqueue(jobs); + expect(batches.map((b) => b.length)).toEqual([100, 50]); + expect(batches[0]?.[0]).toMatchObject({ + url: "https://example.com/api/events", + body: jobs[0], + retries: 5, + deduplicationId: "evt_1_sub_0", + }); + }); +}); diff --git a/packages/mcp-toolkit/src/events/backends/qstash.ts b/packages/mcp-toolkit/src/events/backends/qstash.ts new file mode 100644 index 0000000..d8926a4 --- /dev/null +++ b/packages/mcp-toolkit/src/events/backends/qstash.ts @@ -0,0 +1,259 @@ +/** + * The Upstash backends for events: subscriptions in Upstash Redis, deliveries through QStash. + * + * QStash is the part that makes the webhooks dependable. Each matching subscription becomes one + * QStash message to your delivery endpoint, deduplicated per event and subscription and retried + * with backoff when the host's callback fails. The endpoint signs every attempt fresh with the + * host's secret, so a retry never carries a stale Standard Webhooks timestamp. + */ +import { createHash } from "node:crypto"; +import { Redis } from "@upstash/redis"; +import { Client as QStashClient, Receiver } from "@upstash/qstash"; +import { addTelemetry } from "../../telemetry.js"; +import type { + DeliveryEndpoints, + DeliveryJob, + EventDelivery, + Subscription, + SubscriptionStore, +} from "../types.js"; + +export const DEFAULT_EVENTS_PREFIX = "mcp-events:"; + +export type RedisSubscriptionStoreConfig = { + /** The Upstash Redis client. Defaults to one built from `UPSTASH_REDIS_REST_URL` / `_TOKEN`. */ + redis?: Redis; + /** Key prefix. Defaults to `mcp-events:`. */ + prefix?: string; + /** Set `false` to skip reporting the SDK version in the Redis telemetry header. */ + enableTelemetry?: boolean; +}; + +// Writes the record and its index entry together, with the record's own expiry, and keeps the +// index alive exactly as long as its longest-lived member. +const PUT_SCRIPT = `#!lua flags=allow-key-locking +local ttl = tonumber(ARGV[2]) - tonumber(ARGV[4]) +if ttl <= 0 then return 0 end +redis.call('SET', KEYS[1], ARGV[1], 'PX', ttl) +redis.call('ZADD', KEYS[2], ARGV[2], ARGV[3]) +redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', ARGV[4]) +if redis.call('PTTL', KEYS[2]) < ttl then redis.call('PEXPIRE', KEYS[2], ttl) end +return 1 +`; + +/** + * One string key per subscription (expiring with it), plus a sorted set per `(event, argsKey)` + * scored by expiry. An emit reads only the index entries it can match, so cost grows with the + * number of matching subscriptions, not with the total. + */ +export class RedisSubscriptionStore implements SubscriptionStore { + private readonly prefix: string; + private readonly enableTelemetry: boolean; + private readonly resolveRedis: () => Redis; + private client: Redis | undefined; + + constructor(config: RedisSubscriptionStoreConfig | Redis = {}) { + const options: RedisSubscriptionStoreConfig = isRedisClient(config) + ? { redis: config } + : config; + this.prefix = options.prefix ?? DEFAULT_EVENTS_PREFIX; + this.enableTelemetry = options.enableTelemetry ?? true; + this.resolveRedis = () => options.redis ?? redisFromEnv(); + } + + private get redis(): Redis { + if (!this.client) { + this.client = this.resolveRedis(); + addTelemetry(this.client, { enabled: this.enableTelemetry }); + } + return this.client; + } + + async put(subscription: Subscription): Promise { + await this.redis.eval( + PUT_SCRIPT, + [this.subKey(subscription.id), this.indexKey(subscription.event, subscription.argsKey)], + [ + JSON.stringify(subscription), + String(subscription.expiresAt), + subscription.id, + String(Date.now()), + ], + ); + } + + async get(id: string): Promise { + const subscription = parse(await this.redis.get(this.subKey(id))); + return subscription && subscription.expiresAt > Date.now() ? subscription : null; + } + + async delete(id: string): Promise { + const subscription = parse(await this.redis.get(this.subKey(id))); + if (!subscription) return; + const pipeline = this.redis.pipeline(); + pipeline.del(this.subKey(id)); + pipeline.zrem(this.indexKey(subscription.event, subscription.argsKey), id); + await pipeline.exec(); + } + + async find(event: string, argsKeys: string[]): Promise { + if (argsKeys.length === 0) return []; + const now = Date.now(); + const pipeline = this.redis.pipeline(); + for (const argsKey of argsKeys) { + pipeline.zrange(this.indexKey(event, argsKey), now, "+inf", { byScore: true }); + } + const ids = [...new Set((await pipeline.exec()).flat().map(String))]; + if (ids.length === 0) return []; + const records = await this.redis.mget(...ids.map((id) => this.subKey(id))); + return records + .map(parse) + .filter((sub): sub is Subscription => sub !== null && sub.expiresAt > now); + } + + /** The key a subscription is stored under. */ + subKey(id: string): string { + return `${this.prefix}sub:${id}`; + } + + /** The index key for one `(event, argsKey)` pair. Hashed, because arguments can be long. */ + indexKey(event: string, argsKey: string): string { + const digest = createHash("sha256").update(argsKey).digest("hex").slice(0, 24); + return `${this.prefix}idx:${event}:${digest}`; + } +} + +function parse(raw: unknown): Subscription | null { + if (raw === null || raw === undefined) return null; + // @upstash/redis deserializes JSON values automatically unless told not to. + const value = typeof raw === "string" ? (JSON.parse(raw) as unknown) : raw; + return value && typeof value === "object" ? (value as Subscription) : null; +} + +export type QStashDeliveryConfig = { + /** The public URL of your delivery endpoint, the route that serves `events.createDeliveryHandler()`. */ + url: string; + /** The QStash client. Defaults to one built from `QSTASH_TOKEN` (and `QSTASH_URL`). */ + qstash?: QStashClient; + /** Verifies QStash's signature on deliveries. Defaults to the `QSTASH_*_SIGNING_KEY` env vars. */ + receiver?: Receiver; + /** Retries after the first failed attempt. Defaults to 3. */ + retries?: number; + /** QStash retry delay expression, e.g. `"pow(2, retried) * 1000"`. Defaults to QStash's backoff. */ + retryDelay?: string; + /** Extra headers QStash forwards to your endpoint. */ + headers?: Record; +}; + +/** Every webhook delivery as a QStash message: durable, deduplicated, retried with backoff. */ +export class QStashDelivery implements EventDelivery { + private readonly url: string; + private readonly config: QStashDeliveryConfig; + private endpoints: DeliveryEndpoints | undefined; + private client: QStashClient | undefined; + private verifier: Receiver | undefined; + + constructor(config: QStashDeliveryConfig) { + this.url = config.url; + this.config = config; + } + + attach(endpoints: DeliveryEndpoints): void { + this.endpoints = endpoints; + } + + private get qstash(): QStashClient { + if (!this.client) this.client = this.config.qstash ?? qstashFromEnv(); + return this.client; + } + + private get receiver(): Receiver { + if (!this.verifier) this.verifier = this.config.receiver ?? receiverFromEnv(); + return this.verifier; + } + + async enqueue(jobs: DeliveryJob[]): Promise { + for (let i = 0; i < jobs.length; i += 100) { + await this.qstash.batchJSON( + jobs.slice(i, i + 100).map((job) => ({ + url: this.url, + body: job, + retries: this.config.retries ?? 3, + ...(this.config.retryDelay ? { retryDelay: this.config.retryDelay } : {}), + ...(this.config.headers ? { headers: this.config.headers } : {}), + // Emitting the same event id twice must not POST twice. QStash refuses ':' in the id. + deduplicationId: `${job.envelope.eventId}_${job.subscriptionId}`.replace(/[^\w.-]/g, "-"), + })), + ); + } + } + + /** + * The delivery endpoint: `export const POST = events.createDeliveryHandler()`. + * + * - **200** — delivered, or retrying cannot help (the host answered 410 or 413, or the + * subscription is gone). + * - **500** — the callback failed; QStash retries with backoff. + * - **401** — QStash's signature did not verify. + */ + createDeliveryHandler(): (request: Request) => Promise { + return async (request: Request): Promise => { + const endpoints = this.endpoints; + if (!endpoints) + throw new Error("QStashDelivery is not attached — pass it to createEventLayer()."); + const body = await request.text(); + try { + await this.receiver.verify({ + signature: request.headers.get("upstash-signature") ?? "", + body, + url: this.url, + }); + } catch { + return new Response("invalid signature", { status: 401 }); + } + let job: DeliveryJob; + try { + job = JSON.parse(body) as DeliveryJob; + } catch { + return new Response("malformed body", { status: 400 }); + } + if (!job?.subscriptionId || !job.envelope) + return new Response("malformed body", { status: 400 }); + const outcome = await endpoints.send(job); + return outcome === "retry" + ? new Response("callback failed", { status: 500 }) + : new Response(outcome); + }; + } +} + +function isRedisClient(value: RedisSubscriptionStoreConfig | Redis): value is Redis { + return typeof (value as Redis).mget === "function"; +} + +function redisFromEnv(): Redis { + const { UPSTASH_REDIS_REST_URL: url, UPSTASH_REDIS_REST_TOKEN: token } = process.env; + if (!url || !token) { + throw new Error( + "RedisSubscriptionStore 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( + "createDeliveryHandler 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("QStashDelivery needs a client: pass `qstash`, or set QSTASH_TOKEN."); + return new QStashClient({ token, baseUrl: process.env.QSTASH_URL }); +} diff --git a/packages/mcp-toolkit/src/events/core.test.ts b/packages/mcp-toolkit/src/events/core.test.ts new file mode 100644 index 0000000..1395789 --- /dev/null +++ b/packages/mcp-toolkit/src/events/core.test.ts @@ -0,0 +1,427 @@ +/** + * The events runtime, exercised through a real `McpServer` over JSON-RPC. The "host" is a fake + * `fetch` that plays the callback receiver: it answers the verification challenge and checks the + * Standard Webhooks signature on every POST, the way ChatGPT's receiver does. + */ +import { randomBytes } from "node:crypto"; +import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; +import { describe, expect, it } from "vitest"; +import * as z from "zod"; +import { createTaskLayer } from "../tasks/core.js"; +import { InlineTaskDispatcher, MemoryTaskStore } from "../tasks/backends/memory.js"; +import { createEventLayer, matchKeys, type EventLayerOptions } from "./core.js"; +import { InlineDelivery, MemorySubscriptionStore } from "./backends/memory.js"; +import { taskFinishedEvent } from "./tasks.js"; +import { callbackUrlProblem, verifyWebhook } from "./webhooks.js"; +import type { EventEnvelope, SendOutcome } from "./types.js"; + +const PROTOCOL_VERSION = "2026-07-28"; +const newSecret = () => `whsec_${randomBytes(32).toString("base64")}`; + +type Received = { url: string; headers: Headers; body: Record; valid: boolean }; +type Respond = (r: Received) => Response | undefined; + +/** A callback receiver. `respond` may override each answer after the signature is checked. */ +function host(secrets: Map, respond?: Respond) { + const received: Received[] = []; + const fetchImpl = (async (input: string | URL | Request, init?: Parameters[1]) => { + const url = String(input); + const raw = String(init?.body ?? ""); + const headers = new Headers(init?.headers); + const secret = secrets.get(url) ?? ""; + const r: Received = { + url, + headers, + body: JSON.parse(raw) as Record, + valid: await verifyWebhook(secret, headers, raw), + }; + received.push(r); + const custom = respond?.(r); + if (custom) return custom; + if (!r.valid) return new Response("bad signature", { status: 401 }); + if (r.body.type === "verification") return Response.json({ challenge: r.body.challenge }); + return new Response(null, { status: 204 }); + }) as typeof fetch; + return { + fetch: fetchImpl, + received, + deliveries: () => received.filter((r) => r.body.type !== "verification"), + }; +} + +type JsonRpc = { + result?: Record; + error?: { code: number; message: string; data?: { reason?: string } }; +}; + +const meta = { + "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, + "io.modelcontextprotocol/clientInfo": { name: "host", version: "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, +}; + +function request(method: string, params: Record, id: number): Request { + const headers: Record = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": PROTOCOL_VERSION, + "mcp-method": method, + }; + if (typeof params.name === "string" && method === "tools/call") headers["mcp-name"] = params.name; + return new Request("http://localhost/mcp", { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", id, method, params: { ...params, _meta: meta } }), + }); +} + +const as = (user?: string) => + user + ? { authInfo: { token: "t", clientId: "chatgpt", scopes: [], extra: { userId: user } } } + : undefined; + +function setup(options: Partial & { respond?: Respond } = {}) { + const { respond, ...layerOptions } = options; + const secrets = new Map(); + const receiver = host(secrets, respond); + const outcomes: SendOutcome[] = []; + const store = new MemorySubscriptionStore(); + const events = createEventLayer({ + store, + delivery: new InlineDelivery({ onOutcome: (_job, outcome) => outcomes.push(outcome) }), + secretKey: "test-key", + principal: (auth) => auth?.extra?.userId as string | undefined, + fetch: receiver.fetch, + ...layerOptions, + }); + + const commentCreated = events.define("comment.created", { + description: "A new comment on a document.", + input: z.object({ documentId: z.string(), author: z.string().optional() }), + payload: z.object({ documentId: z.string(), author: z.string(), text: z.string() }), + authorize: (args) => args.documentId !== "secret-doc", + }); + + const handler = createMcpHandler(() => { + const server = new McpServer({ name: "test", version: "1.0.0" }); + events.register(server); + return server; + }); + + let id = 0; + const rpc = async (method: string, params: Record = {}, user?: string) => { + const response = await handler.fetch(request(method, params, ++id), as(user)); + return JSON.parse(await response.text()) as JsonRpc; + }; + + const subscribe = async ( + args: Record, + opts: { + user?: string; + url?: string; + secret?: string; + ttlMs?: number | null; + name?: string; + } = {}, + ) => { + const url = opts.url ?? `https://receiver.example.com/cb/${randomBytes(4).toString("hex")}`; + const secret = opts.secret ?? newSecret(); + secrets.set(url, secret); + const response = await rpc( + "events/subscribe", + { + name: opts.name ?? "comment.created", + arguments: args, + delivery: { mode: "webhook", url, secret }, + ...(opts.ttlMs === undefined ? {} : { ttlMs: opts.ttlMs }), + }, + opts.user, + ); + return { ...response, url, secret }; + }; + + return { events, store, rpc, subscribe, receiver, outcomes, commentCreated }; +} + +describe("events/list and capability", () => { + it("declares the events capability and lists descriptors with JSON schemas", async () => { + const { rpc } = setup(); + const discover = await rpc("server/discover"); + expect((discover.result?.capabilities as Record).events).toEqual({}); + + const list = await rpc("events/list"); + const [event] = list.result?.events as Record[]; + expect(event).toMatchObject({ + name: "comment.created", + description: "A new comment on a document.", + delivery: ["webhook"], + inputSchema: { type: "object", required: ["documentId"] }, + payloadSchema: { type: "object" }, + }); + expect(list.result?.nextCursor).toBeNull(); + }); +}); + +describe("events/subscribe", () => { + it("verifies the callback with a signed challenge, then returns a deterministic id", async () => { + const { subscribe, receiver } = setup(); + const url = "https://r.example.com/a"; + const first = await subscribe({ documentId: "doc_1" }, { user: "alice", url }); + expect(first.error).toBeUndefined(); + expect(first.result).toMatchObject({ cursor: null, truncated: false }); + expect(String(first.result?.id)).toMatch(/^sub_[0-9a-f]{32}$/); + expect(Date.parse(String(first.result?.refreshBefore))).toBeGreaterThan(Date.now()); + + const challenge = receiver.received[0]!; + expect(challenge.valid).toBe(true); + expect(challenge.body.type).toBe("verification"); + expect(challenge.headers.get("webhook-id")).toMatch(/^msg_verification_/); + expect(challenge.headers.get("x-mcp-subscription-id")).toBe(first.result?.id); + + // Same identity, same id; a refresh with the same secret skips the challenge. + const again = await subscribe( + { documentId: "doc_1" }, + { user: "alice", url, secret: first.secret }, + ); + expect(again.result?.id).toBe(first.result?.id); + expect(receiver.received).toHaveLength(1); + + // A different user is a different subscription. + const bob = await subscribe( + { documentId: "doc_1" }, + { user: "bob", url: "https://r.example.com/b" }, + ); + expect(bob.result?.id).not.toBe(first.result?.id); + }); + + it("re-verifies when the secret rotates", async () => { + const { subscribe, receiver } = setup(); + await subscribe({ documentId: "d" }, { url: "https://r.example.com/x" }); + await subscribe({ documentId: "d" }, { url: "https://r.example.com/x" }); + expect(receiver.received.filter((r) => r.body.type === "verification")).toHaveLength(2); + }); + + it("refuses with -32015 when the callback does not echo the challenge", async () => { + const { subscribe, store } = setup({ respond: () => Response.json({ challenge: "nope" }) }); + const response = await subscribe({ documentId: "doc_1" }); + expect(response.error?.code).toBe(-32015); + expect(response.error?.data?.reason).toBe("challenge_failed"); + expect(await store.find("comment.created", ['{"documentId":"doc_1"}'])).toHaveLength(0); + }); + + it("refuses with -32015 when the callback is unreachable or errors", async () => { + const down = setup({ + fetch: (async () => { + throw new TypeError("fetch failed"); + }) as typeof fetch, + }); + expect((await down.subscribe({ documentId: "d" })).error?.data?.reason).toBe("unreachable"); + const erroring = setup({ respond: () => new Response("no", { status: 500 }) }); + expect((await erroring.subscribe({ documentId: "d" })).error).toMatchObject({ + code: -32015, + data: { reason: "http_status" }, + }); + }); + + it("validates event name, mode, arguments, secret, URL and authorization", async () => { + const { subscribe, rpc } = setup(); + const reason = async (p: ReturnType) => (await p).error?.data?.reason; + expect(await reason(subscribe({ documentId: "d" }, { name: "nope" }))).toBe("unknown_event"); + expect(await reason(subscribe({}))).toBe("invalid_arguments"); + expect(await reason(subscribe({ documentId: "d" }, { secret: "whsec_c2hvcnQ=" }))).toBe( + "invalid_secret", + ); + expect( + await reason(subscribe({ documentId: "d" }, { url: "http://receiver.example.com/x" })), + ).toBe("invalid_callback_url"); + expect( + await reason(subscribe({ documentId: "d" }, { url: "https://169.254.169.254/latest" })), + ).toBe("invalid_callback_url"); + expect(await reason(subscribe({ documentId: "secret-doc" }))).toBe("not_authorized"); + const poll = await rpc("events/subscribe", { + name: "comment.created", + arguments: { documentId: "d" }, + delivery: { mode: "poll" }, + }); + expect(poll.error?.data?.reason).toBe("unsupported_delivery_mode"); + }); + + it("grants the requested ttl, capped at the maximum", async () => { + const { subscribe, store } = setup({ defaults: { maxTtlMs: 60_000 } }); + const short = await subscribe({ documentId: "a" }, { ttlMs: 10_000 }); + const long = await subscribe({ documentId: "b" }, { ttlMs: 10 ** 10 }); + const unlimited = await subscribe({ documentId: "c" }, { ttlMs: null }); + const ttlOf = async (id: unknown) => (await store.get(String(id)))!.expiresAt - Date.now(); + expect(await ttlOf(short.result?.id)).toBeLessThanOrEqual(10_000); + expect(await ttlOf(long.result?.id)).toBeLessThanOrEqual(60_000); + expect(await ttlOf(long.result?.id)).toBeGreaterThan(50_000); + expect(await ttlOf(unlimited.result?.id)).toBeGreaterThan(50_000); + }); +}); + +describe("emit", () => { + it("delivers a signed envelope to every subscription whose arguments match", async () => { + const { subscribe, commentCreated, receiver } = setup(); + const all = await subscribe({ documentId: "doc_1" }); + const byAlice = await subscribe({ documentId: "doc_1", author: "alice" }); + await subscribe({ documentId: "doc_1", author: "bob" }); + await subscribe({ documentId: "doc_2" }); + + const result = await commentCreated.emit({ documentId: "doc_1", author: "alice", text: "hi" }); + expect(result.matched).toBe(2); + const deliveries = receiver.deliveries(); + expect(deliveries.map((d) => d.url).sort()).toEqual([all.url, byAlice.url].sort()); + for (const delivery of deliveries) { + expect(delivery.valid).toBe(true); + expect(delivery.body as EventEnvelope).toMatchObject({ + eventId: result.eventId, + name: "comment.created", + cursor: null, + data: { text: "hi" }, + }); + expect(delivery.headers.get("webhook-id")).toBe(result.eventId); + } + }); + + it("matches on explicit args and filters by owner", async () => { + const { subscribe, commentCreated, receiver } = setup(); + await subscribe({ documentId: "doc_1" }, { user: "alice" }); + const bob = await subscribe({ documentId: "doc_1" }, { user: "bob" }); + const result = await commentCreated.emit( + { documentId: "ignored", author: "x", text: "y" }, + { args: { documentId: "doc_1" }, owner: "bob" }, + ); + expect(result.matched).toBe(1); + expect(receiver.deliveries()[0]?.url).toBe(bob.url); + }); + + it("rejects an invalid payload and an oversized one", async () => { + const { commentCreated } = setup(); + await expect(commentCreated.emit({ documentId: "d" } as never)).rejects.toThrow(); + await expect( + commentCreated.emit({ documentId: "d", author: "a", text: "x".repeat(300 * 1024) }), + ).rejects.toThrow(/limit/); + }); + + it("deletes the subscription on 410 and does not retry 413", async () => { + let status = 410; + const { subscribe, commentCreated, outcomes, store } = setup({ + respond: (r) => (r.body.type === "verification" ? undefined : new Response(null, { status })), + }); + const sub = await subscribe({ documentId: "d" }); + await commentCreated.emit({ documentId: "d", author: "a", text: "1" }); + expect(outcomes).toEqual(["gone"]); + expect(await store.get(String(sub.result?.id))).toBeNull(); + + status = 413; + await subscribe({ documentId: "d" }); + await commentCreated.emit({ documentId: "d", author: "a", text: "2" }); + status = 503; + await commentCreated.emit({ documentId: "d", author: "a", text: "3" }); + expect(outcomes).toEqual(["gone", "dropped", "retry"]); + }); + + it("stops delivering after unsubscribe", async () => { + const { subscribe, rpc, commentCreated } = setup(); + const sub = await subscribe({ documentId: "d" }, { user: "alice" }); + const response = await rpc( + "events/unsubscribe", + { + name: "comment.created", + arguments: { documentId: "d" }, + delivery: { mode: "webhook", url: sub.url }, + }, + "alice", + ); + expect(response.result).toBeDefined(); + expect((await commentCreated.emit({ documentId: "d", author: "a", text: "x" })).matched).toBe( + 0, + ); + }); + + it("keeps secrets encrypted at rest", async () => { + const { subscribe, store } = setup(); + const sub = await subscribe({ documentId: "d" }); + const stored = await store.get(String(sub.result?.id)); + expect(stored?.encryptedSecret).toMatch(/^v1\./); + expect(JSON.stringify(stored)).not.toContain(sub.secret.slice(6)); + }); +}); + +describe("task.finished", () => { + it("fires once when a task settles, only to the owner's subscriptions", async () => { + const { events, subscribe, receiver } = setup(); + const taskFinished = taskFinishedEvent(events); + const tasks = createTaskLayer({ + store: new MemoryTaskStore(), + dispatcher: new InlineTaskDispatcher(), + principal: (auth) => auth?.extra?.userId as string | undefined, + onSettle: taskFinished.onSettle, + }); + const handler = createMcpHandler(() => { + const server = new McpServer({ name: "t", version: "1.0.0" }); + tasks.registerTask( + server, + "slow", + { description: "slow", inputSchema: z.object({}) }, + async () => ({ content: [{ type: "text", text: "done" }] }), + ); + return server; + }); + + const alice = await subscribe({}, { user: "alice", name: "task.finished" }); + await subscribe({}, { user: "bob", name: "task.finished" }); + expect(alice.error).toBeUndefined(); + + const response = await handler.fetch( + request("tools/call", { name: "slow", arguments: {} }, 1), + as("alice"), + ); + const taskId = ( + JSON.parse(await response.text()) as { result: { structuredContent: { taskId: string } } } + ).result.structuredContent.taskId; + await new Promise((resolve) => setTimeout(resolve, 50)); + + const deliveries = receiver.deliveries(); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]?.url).toBe(alice.url); + expect(deliveries[0]?.body).toMatchObject({ + name: "task.finished", + eventId: `evt_task_${taskId}`, + data: { taskId, status: "completed", result: { content: [{ type: "text", text: "done" }] } }, + }); + + // A cancel after completion settles nothing, so nothing fires. + await tasks.cancelTask(taskId); + expect(receiver.deliveries()).toHaveLength(1); + }); +}); + +describe("helpers", () => { + it("enumerates every subset of the emitted arguments", () => { + expect(matchKeys({ a: 1, b: 2 }).sort()).toEqual( + ['{"a":1,"b":2}', '{"a":1}', '{"b":2}', "{}"].sort(), + ); + const tooMany = Object.fromEntries([...Array(9).keys()].map((i) => [`k${i}`, i])); + expect(() => matchKeys(tooMany)).toThrow(); + }); + + it("refuses callback URLs that point at the server's own network", () => { + for (const url of [ + "http://example.com", + "https://localhost/x", + "https://10.0.0.1/x", + "https://192.168.1.1/x", + "https://[::1]/x", + "https://[::ffff:127.0.0.1]/x", + "https://metadata.google.internal/x", + "https://user:pw@example.com/x", + "https://intranet/x", + ]) { + expect(callbackUrlProblem(url), url).not.toBeNull(); + } + expect(callbackUrlProblem("https://receiver.example.com/cb")).toBeNull(); + expect(callbackUrlProblem("https://8.8.8.8/cb")).toBeNull(); + expect(callbackUrlProblem("http://localhost:3000/cb", true)).toBeNull(); + }); +}); diff --git a/packages/mcp-toolkit/src/events/core.ts b/packages/mcp-toolkit/src/events/core.ts new file mode 100644 index 0000000..cff0732 --- /dev/null +++ b/packages/mcp-toolkit/src/events/core.ts @@ -0,0 +1,540 @@ +/** + * MCP Events for servers on the official TypeScript SDK. + * + * `createEventLayer` gives you typed event definitions, the three `events/*` methods a host calls + * to subscribe, and an `emit` that fans a payload out to every matching subscription as a signed + * Standard Webhooks POST. Where subscriptions live and how deliveries are retried are the two + * seams in `types.ts`; `upstash.ts` fills them with Redis and QStash. + * + * The wire format follows the MCP Triggers & Events draft as shipped by ChatGPT: webhook delivery + * only, a signed verification challenge before the first event, deterministic subscription ids, + * and expiring subscriptions the host refreshes by subscribing again. + */ +import { createHash, randomBytes } from "node:crypto"; +import { ProtocolError, type McpServer } from "@modelcontextprotocol/server"; +import * as z from "zod"; +import type { CallerAuth } from "../tasks/core.js"; +import type { + DeliveryJob, + EventDelivery, + EventEnvelope, + SendOutcome, + SubscriptionStore, +} from "./types.js"; +import { SecretBox, callbackUrlProblem, decodeSecret, postSigned } from "./webhooks.js"; + +/** The draft's cap on a delivered envelope. */ +export const MAX_PAYLOAD_BYTES = 256 * 1024; +/** `CallbackEndpointError`: the callback failed verification or could not be reached. */ +export const CALLBACK_ENDPOINT_ERROR = -32015; +const INVALID_PARAMS = -32602; + +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_TTL_MS = 7 * DAY_MS; +const DEFAULT_MAX_TTL_MS = 30 * DAY_MS; +const DEFAULT_TIMEOUT_MS = 10_000; +/** Matching enumerates subsets of the emitted arguments, so their count is capped. */ +const MAX_MATCH_KEYS = 8; + +export type { CallerAuth }; + +export type EventLayerOptions = { + /** Where subscriptions live. */ + store: SubscriptionStore; + /** How deliveries get to the callback, with retries. */ + delivery: EventDelivery; + /** + * Encrypts the hosts' signing secrets at rest. Any string works; generate one with + * `openssl rand -base64 32`. Defaults to the `MCP_EVENTS_SECRET_KEY` env var. + */ + secretKey?: string; + /** + * Who is calling, as a stable string — usually your user id. It is part of the subscription id, + * it is handed to each event's `authorize`, and `emit({ owner })` uses it to deliver only to + * that caller's subscriptions. Same contract as the tasks layer's `principal`. + */ + principal?: (auth: CallerAuth | undefined) => string | undefined; + defaults?: { + /** Lifetime granted when the host does not ask for one. Defaults to 7 days. */ + ttlMs?: number; + /** The most the server grants, whatever the host asks. Defaults to 30 days. */ + maxTtlMs?: number; + }; + /** + * Accept `http://` and private-network callback URLs. For local development only: it turns + * off the server-side request forgery checks. + */ + allowInsecureCallbacks?: boolean; + /** Timeout for each POST to a callback. Defaults to 10s. */ + timeoutMs?: number; + /** Override `fetch`, e.g. in tests. */ + fetch?: typeof fetch; +}; + +/** Subscription arguments are always an object schema, so they can be matched field by field. */ +export type EventInputSchema = z.ZodObject; + +export type EventConfig = { + title?: string; + /** Tells the model and the user what the event means. */ + description: string; + /** What a subscriber filters on, e.g. `z.object({ repo: z.string() })`. Defaults to none. */ + input?: Input; + /** The shape of `data` in every delivery. */ + payload: Payload; + /** + * Decides whether a caller may subscribe with these arguments, e.g. whether they can see that + * repository. Runs on every subscribe and refresh. Leave unset to allow everyone. + */ + authorize?: ( + args: z.output, + caller: { principal?: string; auth?: CallerAuth }, + ) => boolean | Promise; + /** + * An extra in-process filter, for conditions exact argument matching cannot express — "only + * comments longer than 100 characters". Runs per subscription on every emit. + */ + match?: (args: z.output, payload: z.output) => boolean; +}; + +export type EmitOptions = { + /** + * The values subscriptions are matched on. A subscription matches when every argument it gave + * equals the value here, so `{ repo: "a", branch: "main" }` reaches subscribers of + * `{ repo: "a" }`, of `{ repo: "a", branch: "main" }`, and of `{}`. + * + * Defaults to the payload fields whose names appear in the input schema. + */ + args?: Partial; + /** Deliver only to subscriptions this principal created. */ + owner?: string; + /** + * The event id, stable across retries and sent as `webhook-id`. Pass your own (e.g. the id of + * the record that changed) so that emitting the same thing twice is deduplicated. + */ + eventId?: string; + /** When the event happened. Defaults to now. */ + timestamp?: Date; +}; + +export type EmitResult = { + eventId: string; + /** How many subscriptions the event was handed to. */ + matched: number; +}; + +export type EventHandle = { + readonly name: string; + /** Validates the payload and delivers it to every matching subscription. */ + emit(payload: z.input, options?: EmitOptions>): Promise; +}; + +/** The descriptor `events/list` returns for each event. */ +export type EventDescriptor = { + name: string; + title?: string; + description: string; + delivery: ["webhook"]; + inputSchema: Record; + payloadSchema: Record; +}; + +export type EventLayer = { + /** Declares an event. Call it at module load, so every instance knows every event. */ + define< + Input extends EventInputSchema = z.ZodObject>, + Payload extends z.ZodType = z.ZodType, + >( + name: string, + config: EventConfig, + ): EventHandle; + /** Declares the `events` capability and serves `events/list`, `events/subscribe`, `events/unsubscribe`. */ + register(server: McpServer): void; + /** The transport's delivery endpoint, e.g. `export const POST = events.createDeliveryHandler()`. */ + createDeliveryHandler(): (request: Request) => Promise; + /** Sends one delivery attempt. Transports call this; exposed for custom ones. */ + send(job: DeliveryJob): Promise; + /** The descriptors `events/list` returns. */ + list(): EventDescriptor[]; + readonly store: SubscriptionStore; + readonly delivery: EventDelivery; +}; + +/** Thrown by `emit` when the envelope would exceed the draft's 256 KiB cap. */ +export class EventPayloadTooLargeError extends Error { + override readonly name = "EventPayloadTooLargeError"; + constructor(readonly bytes: number) { + super(`Event envelope is ${bytes} bytes; the limit is ${MAX_PAYLOAD_BYTES}.`); + } +} + +type Definition = { + config: EventConfig; + input: EventInputSchema; + descriptor: EventDescriptor; +}; + +const subscribeParams = z.looseObject({ + name: z.string(), + arguments: z.record(z.string(), z.unknown()).nullish(), + delivery: z.looseObject({ + mode: z.string(), + url: z.string().optional(), + secret: z.string().optional(), + }), + cursor: z.string().nullish(), + ttlMs: z.number().int().positive().nullish(), +}); + +const unsubscribeParams = z.looseObject({ + name: z.string(), + arguments: z.record(z.string(), z.unknown()).nullish(), + delivery: z.looseObject({ mode: z.string(), url: z.string().optional() }), +}); + +const listParams = z.looseObject({ cursor: z.string().nullish() }); + +export function createEventLayer(options: EventLayerOptions): EventLayer { + const { store, delivery, principal } = options; + const secretKey = options.secretKey ?? getEnv("MCP_EVENTS_SECRET_KEY"); + if (!secretKey) { + throw new Error( + "createEventLayer needs a secretKey (or MCP_EVENTS_SECRET_KEY) to encrypt webhook secrets at rest.", + ); + } + const box = new SecretBox(secretKey); + const ttl = { + initial: options.defaults?.ttlMs ?? DEFAULT_TTL_MS, + max: options.defaults?.maxTtlMs ?? DEFAULT_MAX_TTL_MS, + }; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + /** A secret sealed under a rotated key no longer opens; that just forces a re-verification. */ + const openOrEmpty = (sealed: string): string => { + try { + return box.open(sealed); + } catch { + return ""; + } + }; + const definitions = new Map(); + + function define( + name: string, + config: EventConfig, + ): EventHandle { + if (definitions.has(name)) throw new Error(`Event "${name}" is already defined`); + const input = (config.input ?? z.object({})) as EventInputSchema; + definitions.set(name, { + config: config as unknown as EventConfig, + input, + descriptor: { + name, + ...(config.title ? { title: config.title } : {}), + description: config.description, + delivery: ["webhook"], + inputSchema: jsonSchema(input, "input"), + payloadSchema: jsonSchema(config.payload, "output"), + }, + }); + return { + name, + emit: (payload, emitOptions) => emit(name, payload, emitOptions as EmitOptions), + }; + } + + async function emit( + name: string, + rawPayload: unknown, + emitOptions: EmitOptions = {}, + ): Promise { + const definition = definitions.get(name); + if (!definition) throw new Error(`Unknown event "${name}"`); + const payload = definition.config.payload.parse(rawPayload); + const args = (emitOptions.args ?? pickArgs(definition.input, payload)) as Record< + string, + unknown + >; + + const envelope: EventEnvelope = { + eventId: emitOptions.eventId ?? `evt_${randomBytes(12).toString("hex")}`, + name, + timestamp: (emitOptions.timestamp ?? new Date()).toISOString(), + data: payload, + cursor: null, + }; + const bytes = new TextEncoder().encode(JSON.stringify(envelope)).length; + if (bytes > MAX_PAYLOAD_BYTES) throw new EventPayloadTooLargeError(bytes); + + const candidates = await store.find(name, matchKeys(args)); + const now = Date.now(); + const targets = candidates.filter( + (sub) => + sub.expiresAt > now && + (emitOptions.owner === undefined || String(sub.owner ?? "") === emitOptions.owner) && + (!definition.config.match || definition.config.match(sub.args as never, payload as never)), + ); + if (targets.length > 0) { + await delivery.enqueue(targets.map((sub) => ({ subscriptionId: sub.id, envelope }))); + } + return { eventId: envelope.eventId, matched: targets.length }; + } + + async function send(job: DeliveryJob): Promise { + const sub = await store.get(job.subscriptionId); + if (!sub) return "dropped"; + // Sealed under a key that has since rotated: undeliverable until the host refreshes. + const secret = openOrEmpty(sub.encryptedSecret); + if (!secret) return "dropped"; + let response: Response; + try { + response = await postSigned({ + url: sub.url, + secret, + webhookId: job.envelope.eventId, + body: JSON.stringify(job.envelope), + subscriptionId: sub.id, + timeoutMs, + fetch: options.fetch, + }); + } catch { + return "retry"; + } + if (response.ok) return "delivered"; + if (response.status === 410) { + await store.delete(sub.id); + return "gone"; + } + if (response.status === 413) return "dropped"; + return "retry"; + } + + async function subscribe(params: z.output, ctx: unknown) { + const definition = definitions.get(params.name); + if (!definition) throw invalid(`Unknown event "${params.name}"`, "unknown_event"); + if (params.delivery.mode !== "webhook") { + throw invalid( + `Delivery mode "${params.delivery.mode}" is not supported`, + "unsupported_delivery_mode", + ); + } + const parsed = definition.input.safeParse(params.arguments ?? {}); + if (!parsed.success) { + throw invalid(`Invalid arguments: ${parsed.error.message}`, "invalid_arguments"); + } + const args = parsed.data as Record; + + const secret = params.delivery.secret; + if (!secret || !decodeSecret(secret)) { + throw invalid( + "delivery.secret must be whsec_ followed by 24-64 base64 bytes", + "invalid_secret", + ); + } + const url = params.delivery.url; + const problem = callbackUrlProblem(url, options.allowInsecureCallbacks); + if (problem || !url) + throw invalid(problem ?? "callback URL is missing", "invalid_callback_url"); + + const auth = authOf(ctx); + const owner = principal?.(auth); + if ( + definition.config.authorize && + !(await definition.config.authorize(args, { principal: owner, auth })) + ) { + throw invalid("Not authorized to subscribe with these arguments", "not_authorized"); + } + + const id = subscriptionId(owner, url, params.name, args); + const existing = await store.get(id); + // A refresh with the same secret skips the challenge; a new or rotated secret proves the + // callback again, so a host cannot point someone else's URL at us. + const verified = existing !== null && openOrEmpty(existing.encryptedSecret) === secret; + if (!verified) await verifyCallback(url, secret, id); + + const requested = + params.ttlMs === undefined ? ttl.initial : params.ttlMs === null ? ttl.max : params.ttlMs; + const granted = Math.min(requested, ttl.max); + const now = Date.now(); + const expiresAt = now + granted; + await store.put({ + id, + event: params.name, + args, + argsKey: canonicalJson(args), + url, + encryptedSecret: verified && existing ? existing.encryptedSecret : box.seal(secret), + ...(owner === undefined ? {} : { owner }), + createdAt: existing?.createdAt ?? new Date(now).toISOString(), + expiresAt, + }); + return { + id, + refreshBefore: new Date(expiresAt - Math.min(granted / 10, DAY_MS)).toISOString(), + cursor: null, + truncated: false, + }; + } + + async function unsubscribe(params: z.output, ctx: unknown) { + const definition = definitions.get(params.name); + const parsed = definition?.input.safeParse(params.arguments ?? {}); + if (parsed?.success && params.delivery.url) { + const owner = principal?.(authOf(ctx)); + const args = parsed.data as Record; + await store.delete(subscriptionId(owner, params.delivery.url, params.name, args)); + } + return {}; + } + + async function verifyCallback(url: string, secret: string, id: string): Promise { + const challenge = randomBytes(24).toString("base64url"); + let response: Response; + try { + response = await postSigned({ + url, + secret, + webhookId: `msg_verification_${randomBytes(12).toString("hex")}`, + body: JSON.stringify({ type: "verification", challenge }), + subscriptionId: id, + timeoutMs, + fetch: options.fetch, + }); + } catch (error) { + const timedOut = + error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError"); + throw callbackError( + timedOut ? "timeout" : "unreachable", + "Callback URL could not be reached", + ); + } + if (!response.ok) { + throw callbackError( + "http_status", + `Callback answered the challenge with HTTP ${response.status}`, + { status: response.status }, + ); + } + const body = (await response.json().catch(() => null)) as { challenge?: unknown } | null; + if (body?.challenge !== challenge) { + throw callbackError("challenge_failed", "Callback did not echo the verification challenge"); + } + } + + function register(server: McpServer): void { + const low = server.server; + low.registerCapabilities({ events: {} } as never); + low.setRequestHandler("events/list", { params: listParams }, async () => ({ + events: [...definitions.values()].map((d) => d.descriptor), + nextCursor: null, + })); + low.setRequestHandler("events/subscribe", { params: subscribeParams }, (params, ctx) => + subscribe(params, ctx), + ); + low.setRequestHandler("events/unsubscribe", { params: unsubscribeParams }, (params, ctx) => + unsubscribe(params, ctx), + ); + } + + function createDeliveryHandler(): (request: Request) => Promise { + if (!delivery.createDeliveryHandler) { + throw new Error( + "This delivery sends in-process and has no endpoint to serve. Use QStashDelivery to expose one.", + ); + } + return delivery.createDeliveryHandler(); + } + + delivery.attach?.({ send }); + + return { + define, + register, + createDeliveryHandler, + send, + list: () => [...definitions.values()].map((d) => d.descriptor), + store, + delivery, + }; +} + +/** JSON with object keys sorted at every level, so equal values serialize identically. */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(sortKeys(value)); +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys); + if (value && typeof value === "object") { + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .filter((key) => record[key] !== undefined) + .map((key) => [key, sortKeys(record[key])]), + ); + } + return value; +} + +/** Every `argsKey` an emit with these arguments matches: one per subset, including `{}`. */ +export function matchKeys(args: Record): string[] { + const entries = Object.entries(args).filter(([, value]) => value !== undefined); + if (entries.length > MAX_MATCH_KEYS) { + throw new Error( + `An emit can match on at most ${MAX_MATCH_KEYS} arguments, got ${entries.length}`, + ); + } + const keys: string[] = []; + for (let mask = 0; mask < 1 << entries.length; mask++) { + keys.push(canonicalJson(Object.fromEntries(entries.filter((_, i) => mask & (1 << i))))); + } + return keys; +} + +/** Deterministic, as the draft asks: same owner, callback, event and arguments → same id. */ +export function subscriptionId( + owner: string | undefined, + url: string, + event: string, + args: Record, +): string { + const digest = createHash("sha256") + .update(canonicalJson([owner ?? null, url, event, args])) + .digest("hex"); + return `sub_${digest.slice(0, 32)}`; +} + +function pickArgs(input: EventInputSchema, payload: unknown): Record { + if (!payload || typeof payload !== "object") return {}; + const picked: Record = {}; + for (const key of Object.keys(input.shape)) { + const value = (payload as Record)[key]; + if (value !== undefined) picked[key] = value; + } + return picked; +} + +function jsonSchema(schema: z.ZodType, io: "input" | "output"): Record { + const { $schema: _ignored, ...rest } = z.toJSONSchema(schema, { io }) as Record; + return rest; +} + +function authOf(ctx: unknown): CallerAuth | undefined { + return (ctx as { http?: { authInfo?: CallerAuth } } | undefined)?.http?.authInfo; +} + +function invalid(message: string, reason: string): ProtocolError { + return new ProtocolError(INVALID_PARAMS, message, { reason }); +} + +function callbackError( + reason: string, + message: string, + extra: Record = {}, +): ProtocolError { + return new ProtocolError(CALLBACK_ENDPOINT_ERROR, message, { reason, ...extra }); +} + +function getEnv(name: string): string | undefined { + return typeof process === "object" ? process.env?.[name] : undefined; +} diff --git a/packages/mcp-toolkit/src/events/index.ts b/packages/mcp-toolkit/src/events/index.ts new file mode 100644 index 0000000..6c173b5 --- /dev/null +++ b/packages/mcp-toolkit/src/events/index.ts @@ -0,0 +1,48 @@ +/** + * `@upstash/mcp-toolkit/events` — MCP Events (webhook delivery) for servers on the official + * TypeScript SDK. + * + * The core here is storage-agnostic. The Upstash Redis + QStash backends live behind the + * `@upstash/mcp-toolkit/events/upstash` entry point. + */ +export { + createEventLayer, + canonicalJson, + matchKeys, + subscriptionId, + EventPayloadTooLargeError, + CALLBACK_ENDPOINT_ERROR, + MAX_PAYLOAD_BYTES, + type CallerAuth, + type EmitOptions, + type EmitResult, + type EventConfig, + type EventDescriptor, + type EventHandle, + type EventInputSchema, + type EventLayer, + type EventLayerOptions, +} from "./core.js"; + +export type { + DeliveryEndpoints, + DeliveryJob, + EventDelivery, + EventEnvelope, + SendOutcome, + Subscription, + SubscriptionStore, +} from "./types.js"; + +export { + signWebhook, + verifyWebhook, + decodeSecret, + callbackUrlProblem, + SecretBox, +} from "./webhooks.js"; +export { taskFinishedEvent, type TaskFinishedPayload } from "./tasks.js"; +export { InlineDelivery, MemorySubscriptionStore } from "./backends/memory.js"; + +export { SDK_TELEMETRY } from "../telemetry.js"; +export { VERSION } from "../version.js"; diff --git a/packages/mcp-toolkit/src/events/tasks.ts b/packages/mcp-toolkit/src/events/tasks.ts new file mode 100644 index 0000000..2fbf141 --- /dev/null +++ b/packages/mcp-toolkit/src/events/tasks.ts @@ -0,0 +1,77 @@ +/** + * The bridge between the two halves of the toolkit: a `task.finished` event fired when a task + * settles, so a host that supports MCP Events can stop polling `task_status`. + * + * ```ts + * const events = createEventLayer({ store, delivery }); + * const taskFinished = taskFinishedEvent(events); + * const tasks = createTaskLayer({ store, dispatcher, onSettle: taskFinished.onSettle }); + * ``` + * + * A host subscribes with no arguments to hear about every task the caller starts, or with a + * `taskId` for one task. Deliveries only go to subscriptions made by the task's owner. + */ +import * as z from "zod"; +import type { Task } from "../tasks/types.js"; +import { EventPayloadTooLargeError, type EventLayer } from "./core.js"; + +const taskFinishedInput = z.object({ + taskId: z + .string() + .optional() + .describe("Only this task. Omit to hear about every task you start."), +}); + +const taskFinishedPayload = z.object({ + taskId: z.string(), + status: z.enum(["completed", "failed", "cancelled"]), + statusMessage: z.string().optional(), + /** The tool result, when it fits in the 256 KiB envelope. Otherwise call `task_status`. */ + result: z.record(z.string(), z.unknown()).optional(), + error: z + .object({ code: z.number(), message: z.string(), data: z.unknown().optional() }) + .optional(), +}); + +export type TaskFinishedPayload = z.output; + +export function taskFinishedEvent( + events: EventLayer, + options: { name?: string; description?: string } = {}, +) { + const event = events.define(options.name ?? "task.finished", { + description: + options.description ?? + "A long-running task you started finished: completed, failed or was cancelled. Carries the result when it is small enough.", + input: taskFinishedInput, + payload: taskFinishedPayload, + }); + + async function onSettle(task: Task): Promise { + if (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") + return; + const payload: TaskFinishedPayload = { + taskId: task.taskId, + status: task.status, + ...(task.statusMessage ? { statusMessage: task.statusMessage } : {}), + ...(task.result ? { result: task.result } : {}), + ...(task.error ? { error: task.error } : {}), + }; + const emitOptions = { + args: { taskId: task.taskId }, + // The settle hook can fire twice only if a store misbehaves; a stable id makes it harmless. + eventId: `evt_task_${task.taskId}`, + ...(task.owner === undefined || task.owner === null ? {} : { owner: String(task.owner) }), + }; + try { + await event.emit(payload, emitOptions); + } catch (error) { + if (!(error instanceof EventPayloadTooLargeError)) throw error; + // Too big to inline: send the outcome, and let the host fetch the result with task_status. + const { result: _dropped, ...withoutResult } = payload; + await event.emit(withoutResult, emitOptions); + } + } + + return { event, onSettle }; +} diff --git a/packages/mcp-toolkit/src/events/types.ts b/packages/mcp-toolkit/src/events/types.ts new file mode 100644 index 0000000..1f21bbc --- /dev/null +++ b/packages/mcp-toolkit/src/events/types.ts @@ -0,0 +1,108 @@ +/** + * The storage and delivery seams of the events runtime. + * + * MCP Events says how a host subscribes and what a delivery looks like on the wire. It says + * nothing about where subscriptions live or how a webhook is retried, so those are two + * interfaces: a {@link SubscriptionStore} owns the subscription records, an {@link EventDelivery} + * owns getting a signed POST to the callback. Upstash Redis + QStash (`upstash.ts`) are one + * implementation of each; the in-memory ones (`backends/memory.ts`) are for tests and local dev. + */ + +/** The envelope POSTed to a callback URL, as the MCP Events draft defines it. */ +export type EventEnvelope = { + /** Stable across retries, so the host can drop duplicates. Also sent as `webhook-id`. */ + eventId: string; + /** The event name, e.g. `comment.created`. */ + name: string; + /** ISO-8601, when the event happened. */ + timestamp: string; + /** The payload, matching the event's `payloadSchema`. */ + data: TData; + /** Replay cursor. This runtime does not replay, so it is always `null`. */ + cursor: string | null; +}; + +/** One webhook subscription, as stored. The signing secret is stored encrypted. */ +export type Subscription = { + /** Deterministic: derived from the owner, callback URL, event name and arguments. */ + id: string; + /** The event name. */ + event: string; + /** The validated subscription arguments. */ + args: Record; + /** Canonical JSON of {@link args}; the key emits are matched on. */ + argsKey: string; + /** The callback URL the host gave. */ + url: string; + /** The `whsec_` signing secret, encrypted with the layer's `secretKey`. */ + encryptedSecret: string; + /** The caller that subscribed, from `EventLayerOptions.principal`. */ + owner?: string; + /** ISO-8601. */ + createdAt: string; + /** Epoch milliseconds after which the subscription is gone. */ + expiresAt: number; +}; + +/** + * Durable storage for subscriptions. + * + * Matching is by exact canonical arguments: a subscription to `{ repo: "a" }` is found under the + * key `{"repo":"a"}`. The layer computes every key an emit could match (each subset of the emitted + * arguments, including `{}`) and asks the store for all of them at once, so a store only needs an + * index from `(event, argsKey)` to live subscriptions. + */ +export interface SubscriptionStore { + /** Creates or replaces a subscription. Must have committed before it resolves. */ + put(subscription: Subscription): Promise; + /** Returns a live subscription, or `null` when it is absent or expired. */ + get(id: string): Promise; + /** Removes a subscription. A no-op when it does not exist. */ + delete(id: string): Promise; + /** Returns every live subscription to `event` whose `argsKey` is one of `argsKeys`. */ + find(event: string, argsKeys: string[]): Promise; +} + +/** What the transport is asked to send: one envelope to one subscription. */ +export type DeliveryJob = { + subscriptionId: string; + envelope: EventEnvelope; +}; + +/** The outcome of one POST to a callback, as the layer classifies it. */ +export type SendOutcome = + /** 2xx. Done. */ + | "delivered" + /** 410 Gone: the host dropped the subscription. The layer has deleted it; do not retry. */ + | "gone" + /** 413, or the subscription no longer exists: retrying cannot help. */ + | "dropped" + /** Anything else. The transport should retry. */ + | "retry"; + +/** The layer's entry point, handed to a delivery transport by {@link EventDelivery.attach}. */ +export type DeliveryEndpoints = { + /** + * Loads the subscription, signs the envelope with a fresh timestamp, POSTs it, and classifies + * the response. Call it once per attempt: Standard Webhooks signatures are timestamped, so a + * retry must be signed again rather than replayed. + */ + send(job: DeliveryJob): Promise; +}; + +/** + * Durable delivery transport. + * + * The draft spec asks for retries with backoff and a stable event id across them. A queue does + * exactly that, so `QStashDelivery` enqueues one message per subscription and runs + * {@link DeliveryEndpoints.send} from its delivery endpoint. `InlineDelivery` sends in-process + * with no retries, for tests and local development. + */ +export interface EventDelivery { + /** Accepts the jobs durably (or sends them) before resolving. */ + enqueue(jobs: DeliveryJob[]): Promise; + /** Receives the layer's entry point, once, when passed to `createEventLayer`. */ + attach?(endpoints: DeliveryEndpoints): void; + /** The transport's own HTTP endpoint, when it delivers through one. */ + createDeliveryHandler?(): (request: Request) => Promise; +} diff --git a/packages/mcp-toolkit/src/events/upstash.ts b/packages/mcp-toolkit/src/events/upstash.ts new file mode 100644 index 0000000..59238e9 --- /dev/null +++ b/packages/mcp-toolkit/src/events/upstash.ts @@ -0,0 +1,12 @@ +/** + * The Upstash backends for events: `RedisSubscriptionStore` for the subscriptions and + * `QStashDelivery` for durable, retried webhook deliveries. Needs `@upstash/redis` and + * `@upstash/qstash` installed. + */ +export { + RedisSubscriptionStore, + QStashDelivery, + DEFAULT_EVENTS_PREFIX, + type RedisSubscriptionStoreConfig, + type QStashDeliveryConfig, +} from "./backends/qstash.js"; diff --git a/packages/mcp-toolkit/src/events/webhooks.ts b/packages/mcp-toolkit/src/events/webhooks.ts new file mode 100644 index 0000000..a97fdd6 --- /dev/null +++ b/packages/mcp-toolkit/src/events/webhooks.ts @@ -0,0 +1,223 @@ +/** + * The webhook plumbing MCP Events needs: + * + * - Standard Webhooks signing (`webhook-id`, `webhook-timestamp`, `webhook-signature`). + * - Encrypting the host's signing secret at rest (AES-256-GCM). + * - Checking a callback URL before the server ever POSTs to it. + */ +import { + createCipheriv, + createDecipheriv, + createHash, + createHmac, + randomBytes, + timingSafeEqual, +} from "node:crypto"; +import { isIP } from "node:net"; + +/** The secret length the MCP Events draft allows, in decoded bytes. */ +export const SECRET_MIN_BYTES = 24; +export const SECRET_MAX_BYTES = 64; + +/** + * Decodes a `whsec_` secret into its key bytes, or returns `null` when it is malformed or + * outside the 24–64 byte range the draft allows. + */ +export function decodeSecret(secret: unknown): Buffer | null { + if (typeof secret !== "string" || !secret.startsWith("whsec_")) return null; + const body = secret.slice("whsec_".length); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return null; + const bytes = Buffer.from(body, "base64"); + if (bytes.length < SECRET_MIN_BYTES || bytes.length > SECRET_MAX_BYTES) return null; + return bytes; +} + +/** + * Signs a webhook body the Standard Webhooks way: base64 HMAC-SHA256 over + * `${id}.${timestamp}.${body}`, sent as `v1,`. + * + * Returns the three headers to send. `timestampSeconds` defaults to now; every attempt should be + * signed fresh, because receivers reject stale timestamps. + */ +export async function signWebhook( + secret: string, + id: string, + body: string, + timestampSeconds = Math.floor(Date.now() / 1000), +): Promise> { + const key = decodeSecret(secret); + if (!key) throw new Error("Invalid webhook secret"); + const signature = createHmac("sha256", key) + .update(`${id}.${timestampSeconds}.${body}`) + .digest("base64"); + return { + "webhook-id": id, + "webhook-timestamp": String(timestampSeconds), + "webhook-signature": `v1,${signature}`, + }; +} + +/** + * Verifies a Standard Webhooks signature, with a tolerance on the timestamp. Exported for tests + * and for anyone writing a receiver; the server side of MCP Events only signs. + */ +export async function verifyWebhook( + secret: string, + headers: Headers, + body: string, + toleranceSeconds = 300, +): Promise { + const id = headers.get("webhook-id"); + const timestamp = headers.get("webhook-timestamp"); + const signature = headers.get("webhook-signature"); + if (!id || !timestamp || !signature) return false; + const ts = Number(timestamp); + if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false; + if (!decodeSecret(secret)) return false; + const expected = (await signWebhook(secret, id, body, ts))["webhook-signature"]; + // Several space-separated signatures are allowed (key rotation). + return signature.split(" ").some((candidate) => safeEqual(candidate, expected)); +} + +function safeEqual(a: string, b: string): boolean { + const left = Buffer.from(a); + const right = Buffer.from(b); + return left.length === right.length && timingSafeEqual(left, right); +} + +/** + * Encrypts and decrypts the hosts' signing secrets with a server-side key, so a leaked database + * dump cannot be used to forge events. + * + * The key can be any string (it is hashed to 32 bytes); generate one with + * `openssl rand -base64 32`. Rotating it invalidates stored subscriptions, which hosts recreate + * on their next refresh. + */ +export class SecretBox { + private readonly key: Buffer; + + constructor(secretKey: string) { + if (!secretKey) throw new Error("SecretBox needs a non-empty secretKey"); + this.key = createHash("sha256").update(secretKey).digest(); + } + + seal(plaintext: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", this.key, iv); + const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `v1.${iv.toString("base64")}.${Buffer.concat([data, tag]).toString("base64")}`; + } + + open(sealed: string): string { + const [version, iv, payload] = sealed.split("."); + if (version !== "v1" || !iv || !payload) throw new Error("Unrecognized sealed secret"); + const bytes = Buffer.from(payload, "base64"); + const decipher = createDecipheriv("aes-256-gcm", this.key, Buffer.from(iv, "base64")); + decipher.setAuthTag(bytes.subarray(bytes.length - 16)); + return Buffer.concat([ + decipher.update(bytes.subarray(0, bytes.length - 16)), + decipher.final(), + ]).toString("utf8"); + } +} + +/** + * Why a callback URL was refused, or `null` when it is acceptable. + * + * The server POSTs to a URL a client chose, which is the textbook server-side request forgery + * setup. This refuses non-HTTPS URLs, credentials in the URL, and hosts that name the server's own + * network: `localhost`, single-label, `.local` / `.internal` names, and private, loopback, + * link-local and carrier-NAT IP literals. Redirects are never followed when posting. + * + * It does not resolve DNS, so a public name that resolves to a private address is not caught + * here. If your server runs next to sensitive internal services, put egress filtering in front of + * it as well. + */ +export function callbackUrlProblem(raw: unknown, allowInsecure = false): string | null { + if (typeof raw !== "string") return "callback URL is missing"; + let url: URL; + try { + url = new URL(raw); + } catch { + return "callback URL is not a valid URL"; + } + if (allowInsecure) { + return url.protocol === "https:" || url.protocol === "http:" + ? null + : "callback URL must be http(s)"; + } + if (url.protocol !== "https:") return "callback URL must use https"; + if (url.username || url.password) return "callback URL must not contain credentials"; + const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + const ipVersion = isIP(host); + if (ipVersion !== 0) return isPrivateAddress(host) ? "callback URL must be a public host" : null; + if ( + !host.includes(".") || + host === "localhost" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.endsWith(".internal") + ) { + return "callback URL must be a public host"; + } + return null; +} + +function isPrivateAddress(ip: string): boolean { + if (isIP(ip) === 4) return isPrivateV4(ip); + const v6 = ip.toLowerCase(); + const dotted = /^(?:0*:)*:?ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v6); + if (dotted?.[1]) return isPrivateV4(dotted[1]); + // The URL parser normalizes ::ffff:127.0.0.1 to ::ffff:7f00:1. + const hex = /^(?:0*:)*:?ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(v6); + if (hex?.[1] && hex[2]) { + const hi = parseInt(hex[1], 16); + const lo = parseInt(hex[2], 16); + return isPrivateV4(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`); + } + return ( + v6 === "::" || + v6 === "::1" || + /^f[cd]/.test(v6) || // fc00::/7 unique local + /^fe[89ab]/.test(v6) // fe80::/10 link-local + ); +} + +function isPrivateV4(ip: string): boolean { + const [a = 0, b = 0] = ip.split(".").map(Number); + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + a >= 224 + ); +} + +/** POSTs a signed JSON body to a callback, without following redirects. */ +export async function postSigned(options: { + url: string; + secret: string; + webhookId: string; + body: string; + subscriptionId: string; + timeoutMs: number; + fetch?: typeof fetch; +}): Promise { + const headers = await signWebhook(options.secret, options.webhookId, options.body); + return await (options.fetch ?? fetch)(options.url, { + method: "POST", + redirect: "manual", + signal: AbortSignal.timeout(options.timeoutMs), + headers: { + "content-type": "application/json", + "x-mcp-subscription-id": options.subscriptionId, + ...headers, + }, + body: options.body, + }); +} diff --git a/packages/mcp-tasks/src/backends/memory.ts b/packages/mcp-toolkit/src/tasks/backends/memory.ts similarity index 100% rename from packages/mcp-tasks/src/backends/memory.ts rename to packages/mcp-toolkit/src/tasks/backends/memory.ts diff --git a/packages/mcp-tasks/src/backends/qstash.test.ts b/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts similarity index 99% rename from packages/mcp-tasks/src/backends/qstash.test.ts rename to packages/mcp-toolkit/src/tasks/backends/qstash.test.ts index fc875bd..4548ef1 100644 --- a/packages/mcp-tasks/src/backends/qstash.test.ts +++ b/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts @@ -1,7 +1,7 @@ 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"; +import { cleanupKeys, hasRedisCreds, testRedis, uniquePrefix } from "../../test-support.js"; const makeTask = (overrides: Partial = {}): Task => { const now = new Date().toISOString(); diff --git a/packages/mcp-tasks/src/backends/qstash.ts b/packages/mcp-toolkit/src/tasks/backends/qstash.ts similarity index 99% rename from packages/mcp-tasks/src/backends/qstash.ts rename to packages/mcp-toolkit/src/tasks/backends/qstash.ts index 7d4eb45..33d2d9d 100644 --- a/packages/mcp-tasks/src/backends/qstash.ts +++ b/packages/mcp-toolkit/src/tasks/backends/qstash.ts @@ -21,7 +21,7 @@ import { type TaskStore, type TerminalTaskPatch, } from "../types.js"; -import { addTelemetry } from "../telemetry.js"; +import { addTelemetry } from "../../telemetry.js"; /** Default key prefix for task hashes: `mcp:task:`. */ export const DEFAULT_TASK_PREFIX = "mcp:task:"; @@ -87,7 +87,7 @@ const OBJECT_FIELDS: ReadonlySet = new Set(["args", "result", "error"]); * 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 = ` +const UPDATE_SCRIPT = `#!lua flags=allow-key-locking local current = redis.call('HGET', KEYS[1], 'status') if not current then return 0 end local terminals = tonumber(ARGV[1]) @@ -100,7 +100,7 @@ end return 1 `; -const SETTLE_SCRIPT = ` +const SETTLE_SCRIPT = `#!lua flags=allow-key-locking local current = redis.call('HGET', KEYS[1], 'status') if not current then return 0 end local terminals = tonumber(ARGV[1]) diff --git a/packages/mcp-tasks/src/backends/workflow.test.ts b/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts similarity index 100% rename from packages/mcp-tasks/src/backends/workflow.test.ts rename to packages/mcp-toolkit/src/tasks/backends/workflow.test.ts diff --git a/packages/mcp-tasks/src/backends/workflow.ts b/packages/mcp-toolkit/src/tasks/backends/workflow.ts similarity index 100% rename from packages/mcp-tasks/src/backends/workflow.ts rename to packages/mcp-toolkit/src/tasks/backends/workflow.ts diff --git a/packages/mcp-tasks/src/core.test.ts b/packages/mcp-toolkit/src/tasks/core.test.ts similarity index 99% rename from packages/mcp-tasks/src/core.test.ts rename to packages/mcp-toolkit/src/tasks/core.test.ts index 6e7e56f..bd518d4 100644 --- a/packages/mcp-tasks/src/core.test.ts +++ b/packages/mcp-toolkit/src/tasks/core.test.ts @@ -8,7 +8,7 @@ import * as z from "zod"; import { createTaskLayer } from "./core.js"; import { InlineTaskDispatcher, MemoryTaskStore } from "./backends/memory.js"; import type { TaskContext, TaskLayer, TaskToolConfig, WireTask } from "./index.js"; -import { sleep } from "./test-support.js"; +import { sleep } from "../test-support.js"; const PROTOCOL_VERSION = "2026-07-28"; diff --git a/packages/mcp-tasks/src/core.ts b/packages/mcp-toolkit/src/tasks/core.ts similarity index 92% rename from packages/mcp-tasks/src/core.ts rename to packages/mcp-toolkit/src/tasks/core.ts index e58736a..fe5e2f2 100644 --- a/packages/mcp-tasks/src/core.ts +++ b/packages/mcp-toolkit/src/tasks/core.ts @@ -77,6 +77,16 @@ export type TaskLayerOptions = { principal?: (auth: CallerAuth | undefined) => string | undefined; /** Rename the two shared tools, e.g. to namespace them next to other servers' tools. */ toolNames?: { status?: string; cancel?: string }; + /** + * Called once per task, right after it reaches `completed`, `failed` or `cancelled`. Only the + * write that performed the transition triggers it, so a redelivery or a repeated cancel never + * calls it twice. + * + * The usual consumer is the `task.finished` event from `@upstash/mcp-toolkit/events`, which + * turns this into a webhook so a client can stop polling. A throw here is logged and swallowed: + * the task is already settled and must stay that way. + */ + onSettle?: (task: Task) => void | Promise; }; export type TaskToolConfig> = { @@ -192,7 +202,19 @@ export type TaskLayer = { export function createTaskLayer( options: TaskLayerOptions, ): TaskLayer { - const { store, dispatcher, defaults = {}, principal } = options; + const { store, dispatcher, defaults = {}, principal, onSettle } = options; + + /** Runs the settle hook for a transition this call performed; never throws. */ + async function settled(task: T): Promise { + if (task && onSettle) { + try { + await onSettle(task); + } catch (error) { + console.warn(`[mcp-toolkit] onSettle failed for task ${task.taskId}:`, error); + } + } + return task; + } const toolNames = { status: options.toolNames?.status ?? DEFAULT_TOOL_NAMES.status, cancel: options.toolNames?.cancel ?? DEFAULT_TOOL_NAMES.cancel, @@ -314,17 +336,16 @@ export function createTaskLayer( // 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; + const cancelled = await settled( + await store.settle(taskId, { status: "cancelled", statusMessage: "Cancelled by client" }), + ); + const dispatchId = cancelled?.dispatchId ?? task.dispatchId; if (dispatchId) { // A message already in flight cannot be recalled; that is why cancellation is cooperative, // and why the handler still checks `isCancelled()`. await dispatcher.cancel(dispatchId).catch(() => undefined); } - return settled ?? (await read(taskId)); + return cancelled ?? (await read(taskId)); } async function executeTask( @@ -377,20 +398,20 @@ export function createTaskLayer( // 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)); + const completed = await settled( + await store.settle(taskId, { + status: "completed", + statusMessage: completedMessages.get(task.name) ?? "Completed", + result, + }), + ); + return completed ?? (await store.get(taskId)); } async function failTask(taskId: string, error: TaskError): Promise { - return await store.settle(taskId, { - status: "failed", - statusMessage: "Execution failed", - error, - }); + return await settled( + await store.settle(taskId, { status: "failed", statusMessage: "Execution failed", error }), + ); } /** Reads a task, folding "unknown" and "expired" into null. */ diff --git a/packages/mcp-tasks/src/index.ts b/packages/mcp-toolkit/src/tasks/index.ts similarity index 69% rename from packages/mcp-tasks/src/index.ts rename to packages/mcp-toolkit/src/tasks/index.ts index feba869..8d02f3a 100644 --- a/packages/mcp-tasks/src/index.ts +++ b/packages/mcp-toolkit/src/tasks/index.ts @@ -1,8 +1,8 @@ /** - * `@upstash/mcp-tasks` — durable long-running tools for MCP servers on the official TypeScript SDK. + * `@upstash/mcp-toolkit/tasks` — durable long-running tools for MCP servers on 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. + * `@upstash/mcp-toolkit/tasks/upstash` entry point, so bringing your own store costs you nothing. */ export { createTaskLayer, @@ -34,5 +34,5 @@ export { export { InlineTaskDispatcher, MemoryTaskStore } from "./backends/memory.js"; -export { SDK_TELEMETRY } from "./telemetry.js"; -export { VERSION } from "./version.js"; +export { SDK_TELEMETRY } from "../telemetry.js"; +export { VERSION } from "../version.js"; diff --git a/packages/mcp-tasks/src/types.ts b/packages/mcp-toolkit/src/tasks/types.ts similarity index 100% rename from packages/mcp-tasks/src/types.ts rename to packages/mcp-toolkit/src/tasks/types.ts diff --git a/packages/mcp-tasks/src/upstash.ts b/packages/mcp-toolkit/src/tasks/upstash.ts similarity index 100% rename from packages/mcp-tasks/src/upstash.ts rename to packages/mcp-toolkit/src/tasks/upstash.ts diff --git a/packages/mcp-tasks/src/telemetry.ts b/packages/mcp-toolkit/src/telemetry.ts similarity index 96% rename from packages/mcp-tasks/src/telemetry.ts rename to packages/mcp-toolkit/src/telemetry.ts index f0cf8c8..055b9ff 100644 --- a/packages/mcp-tasks/src/telemetry.ts +++ b/packages/mcp-toolkit/src/telemetry.ts @@ -9,7 +9,7 @@ type TelemetryCapableRedis = { }; /** The telemetry tag of this package. */ -export const SDK_TELEMETRY = `@upstash/mcp-tasks@${VERSION}`; +export const SDK_TELEMETRY = `@upstash/mcp-toolkit@${VERSION}`; /** * The redis client *appends* to the telemetry header on every `addTelemetry` call, so each client @@ -22,7 +22,7 @@ const getSafeEnv = (): Record => /** * 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`. + * producing a header like `@upstash/redis@1.38.0,@upstash/mcp-toolkit@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. diff --git a/packages/mcp-tasks/src/test-support.ts b/packages/mcp-toolkit/src/test-support.ts similarity index 95% rename from packages/mcp-tasks/src/test-support.ts rename to packages/mcp-toolkit/src/test-support.ts index 705a4f8..754013c 100644 --- a/packages/mcp-tasks/src/test-support.ts +++ b/packages/mcp-toolkit/src/test-support.ts @@ -23,7 +23,7 @@ export function testRedis(): Redis { /** 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)}:`; + return `test:mcp-toolkit:${label}:${randomUUID().slice(0, 8)}:`; } /** Delete every key under a key prefix (best-effort cleanup in afterAll hooks). */ diff --git a/packages/mcp-tasks/src/version.ts b/packages/mcp-toolkit/src/version.ts similarity index 100% rename from packages/mcp-tasks/src/version.ts rename to packages/mcp-toolkit/src/version.ts diff --git a/packages/mcp-tasks/tsconfig.json b/packages/mcp-toolkit/tsconfig.json similarity index 100% rename from packages/mcp-tasks/tsconfig.json rename to packages/mcp-toolkit/tsconfig.json diff --git a/packages/mcp-toolkit/tsup.config.ts b/packages/mcp-toolkit/tsup.config.ts new file mode 100644 index 0000000..6b56394 --- /dev/null +++ b/packages/mcp-toolkit/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + "tasks/index": "src/tasks/index.ts", + "tasks/upstash": "src/tasks/upstash.ts", + "events/index": "src/events/index.ts", + "events/upstash": "src/events/upstash.ts", + }, + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + treeshake: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2f1cf9..bcd1bcc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,14 +234,14 @@ importers: specifier: 7.0.2 version: 7.0.2 - examples/mcp-tasks-demo: + examples/mcp-toolkit-demo: dependencies: '@modelcontextprotocol/server': specifier: ^2.0.0 version: 2.3.1 - '@upstash/mcp-tasks': + '@upstash/mcp-toolkit': specifier: workspace:* - version: link:../../packages/mcp-tasks + version: link:../../packages/mcp-toolkit '@upstash/qstash': specifier: ^2.11.3 version: 2.12.0 @@ -410,7 +410,7 @@ importers: specifier: 7.0.2 version: 7.0.2 - packages/mcp-tasks: + packages/mcp-toolkit: dependencies: zod: specifier: ^4.2.0 diff --git a/scripts/sync-version.mjs b/scripts/sync-version.mjs index 3bb9694..7b21b5f 100644 --- a/scripts/sync-version.mjs +++ b/scripts/sync-version.mjs @@ -19,7 +19,7 @@ const TARGETS = { "packages/eve": "src/version.ts", "packages/eve-extension": "extension/lib/version.ts", "packages/tanstack-ai": "src/version.ts", - "packages/mcp-tasks": "src/version.ts", + "packages/mcp-toolkit": "src/version.ts", }; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); From 5525f8810a156b29d02f1223edf597c8d77873fa Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 07:24:01 +0000 Subject: [PATCH 16/33] DX-3022: export the events backends from @upstash/mcp-toolkit/events Drop the /events/upstash entry point: RedisSubscriptionStore and QStashDelivery now come from @upstash/mcp-toolkit/events directly. /tasks/upstash stays as is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd --- .changeset/mcp-toolkit-initial.md | 2 +- CLAUDE.md | 7 ++++--- examples/mcp-toolkit-demo/app/lib/events.ts | 8 ++++++-- packages/mcp-toolkit/README.md | 9 ++++----- packages/mcp-toolkit/package.json | 4 ---- packages/mcp-toolkit/src/events/index.ts | 12 ++++++++++-- packages/mcp-toolkit/src/events/upstash.ts | 12 ------------ packages/mcp-toolkit/tsup.config.ts | 1 - 8 files changed, 25 insertions(+), 30 deletions(-) delete mode 100644 packages/mcp-toolkit/src/events/upstash.ts diff --git a/.changeset/mcp-toolkit-initial.md b/.changeset/mcp-toolkit-initial.md index 83b982a..81b3447 100644 --- a/.changeset/mcp-toolkit-initial.md +++ b/.changeset/mcp-toolkit-initial.md @@ -17,7 +17,7 @@ fires once per finished task. `events.define(...)` handles with `emit(payload)`, `events/list`, `events/subscribe` and `events/unsubscribe` registered on your server, a signed verification challenge, deterministic subscription ids, expiry and refresh, SSRF checks on callback URLs, and signing secrets encrypted -at rest. `/events/upstash` provides `RedisSubscriptionStore` and `QStashDelivery`, which signs each +at rest. It ships `RedisSubscriptionStore` and `QStashDelivery` in the same entry point; the latter signs each attempt with Standard Webhooks and lets QStash retry failures (`export const POST = events.createDeliveryHandler()`). `taskFinishedEvent` bridges the two, so an event-capable host can stop polling. diff --git a/CLAUDE.md b/CLAUDE.md index bf9cf63..ebcfb20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ 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-tanstack-ai` (`packages/tanstack-ai`) | TanStack AI backends: persistence stores, `StreamDurability`, `LockStore`, `MemoryAdapter`, middlewares, search tools. | | `@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-toolkit` (`packages/mcp-toolkit`) | Durable building blocks for the official `@modelcontextprotocol/server` v2: `/tasks` (**long-running MCP tools** — start + `task_status` + `task_cancel`) and `/events` (**MCP Events**, webhook delivery). Each has a `/upstash` entry point. Renamed from `@upstash/mcp-tasks` before its first release. **Not an `agentkit-*` package** — separate name, versioned independently (the changesets `linked` glob only covers `@upstash/agentkit-*`), and it depends on none of the others. | +| `@upstash/mcp-toolkit` (`packages/mcp-toolkit`) | Durable building blocks for the official `@modelcontextprotocol/server` v2: `/tasks` (**long-running MCP tools** — start + `task_status` + `task_cancel`) and `/events` (**MCP Events**, webhook delivery). `/tasks` has a `/tasks/upstash` entry point; `/events` exports its Redis/QStash backends directly (no `/events/upstash`). Renamed from `@upstash/mcp-tasks` before its first release. **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), `eve-extension-demo` (a minimal eve scaffold that mounts the extension), and `mcp-toolkit-demo` @@ -679,8 +679,9 @@ implements TanStack AI's own backend contracts (see its section below) — keep `$count`, `$histogram`, `$percentiles`, `$cardinality`. ## MCP toolkit facts (`packages/mcp-toolkit`) — IMPORTANT -- **Layout:** `src/tasks/` and `src/events/`, each with `index.ts` (core, storage-agnostic) and - `upstash.ts` (Redis/QStash/Workflow backends), built to `dist/{tasks,events}/{index,upstash}.js`. +- **Layout:** `src/tasks/` (`index.ts` core + `upstash.ts` backends) and `src/events/` (`index.ts` + exports core *and* Redis/QStash backends — one entry point by choice), built to + `dist/tasks/{index,upstash}.js` and `dist/events/index.js`. There is no root export. Shared: `src/telemetry.ts` (tag `@upstash/mcp-toolkit`), `src/version.ts`. - **Tools mode, not the Tasks extension (since 2026-10).** A task tool answers with an ordinary tool result (`structuredContent` = the task object, plus a text line telling the model to poll); two diff --git a/examples/mcp-toolkit-demo/app/lib/events.ts b/examples/mcp-toolkit-demo/app/lib/events.ts index 68b6dbb..1fe0614 100644 --- a/examples/mcp-toolkit-demo/app/lib/events.ts +++ b/examples/mcp-toolkit-demo/app/lib/events.ts @@ -5,8 +5,12 @@ * Subscriptions live in Redis with their signing secrets encrypted; each delivery is a QStash * message to `/api/events`, which signs and POSTs it to the host and lets QStash retry failures. */ -import { createEventLayer, taskFinishedEvent } from "@upstash/mcp-toolkit/events"; -import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events/upstash"; +import { + createEventLayer, + QStashDelivery, + RedisSubscriptionStore, + taskFinishedEvent, +} from "@upstash/mcp-toolkit/events"; const APP_URL = process.env.APP_URL ?? "http://127.0.0.1:3000"; diff --git a/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md index 4fff987..0f357b1 100644 --- a/packages/mcp-toolkit/README.md +++ b/packages/mcp-toolkit/README.md @@ -8,8 +8,8 @@ QStash and Workflow. | [`@upstash/mcp-toolkit/tasks`](#tasks-long-running-tools) | **Long-running tools.** A tool answers at once with a task id; the model polls `task_status` for progress and the result. Works in every client today. | | [`@upstash/mcp-toolkit/events`](#events-webhooks-that-wake-the-agent) | **MCP Events.** Hosts subscribe to your events and get a signed webhook when one happens, so an agent wakes up instead of polling. Works in ChatGPT today. | -Each has a storage-agnostic core plus a `/upstash` entry point with the Redis and QStash backends, -and in-memory backends for tests. They also compose: a `task.finished` event tells an +`/tasks` keeps its Upstash backends behind `/tasks/upstash`; `/events` ships them in the same +entry point. Both include in-memory backends for tests. They also compose: a `task.finished` event tells an event-capable host that a task settled, so it can skip polling. ## Install @@ -354,8 +354,7 @@ subscription ids, expiry and refresh, Standard Webhooks signing, and durable ret ```ts // lib/events.ts -import { createEventLayer } from "@upstash/mcp-toolkit/events"; -import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events/upstash"; +import { createEventLayer, QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; import * as z from "zod"; export const events = createEventLayer({ @@ -582,7 +581,7 @@ interface EventDelivery { | `callbackUrlProblem`, `decodeSecret`, `SecretBox` | The callback, secret and encryption checks the layer uses | | `EventPayloadTooLargeError`, `CALLBACK_ENDPOINT_ERROR`, `MAX_PAYLOAD_BYTES` | Limits and errors | | `MemorySubscriptionStore`, `InlineDelivery` | Non-durable backends for tests | -| `@upstash/mcp-toolkit/events/upstash` | `RedisSubscriptionStore`, `QStashDelivery` | +| `RedisSubscriptionStore`, `QStashDelivery` | The Upstash backends (need `@upstash/redis` and `@upstash/qstash`) | diff --git a/packages/mcp-toolkit/package.json b/packages/mcp-toolkit/package.json index 4418986..4fcff65 100644 --- a/packages/mcp-toolkit/package.json +++ b/packages/mcp-toolkit/package.json @@ -25,10 +25,6 @@ "./events": { "types": "./dist/events/index.d.ts", "import": "./dist/events/index.js" - }, - "./events/upstash": { - "types": "./dist/events/upstash.d.ts", - "import": "./dist/events/upstash.js" } }, "files": [ diff --git a/packages/mcp-toolkit/src/events/index.ts b/packages/mcp-toolkit/src/events/index.ts index 6c173b5..d9b4acd 100644 --- a/packages/mcp-toolkit/src/events/index.ts +++ b/packages/mcp-toolkit/src/events/index.ts @@ -2,8 +2,9 @@ * `@upstash/mcp-toolkit/events` — MCP Events (webhook delivery) for servers on the official * TypeScript SDK. * - * The core here is storage-agnostic. The Upstash Redis + QStash backends live behind the - * `@upstash/mcp-toolkit/events/upstash` entry point. + * One entry point: the core, the Upstash backends (`RedisSubscriptionStore`, `QStashDelivery`), + * and in-memory backends for tests. Importing it needs `@upstash/redis` and `@upstash/qstash` + * installed. */ export { createEventLayer, @@ -43,6 +44,13 @@ export { } from "./webhooks.js"; export { taskFinishedEvent, type TaskFinishedPayload } from "./tasks.js"; export { InlineDelivery, MemorySubscriptionStore } from "./backends/memory.js"; +export { + RedisSubscriptionStore, + QStashDelivery, + DEFAULT_EVENTS_PREFIX, + type RedisSubscriptionStoreConfig, + type QStashDeliveryConfig, +} from "./backends/qstash.js"; export { SDK_TELEMETRY } from "../telemetry.js"; export { VERSION } from "../version.js"; diff --git a/packages/mcp-toolkit/src/events/upstash.ts b/packages/mcp-toolkit/src/events/upstash.ts deleted file mode 100644 index 59238e9..0000000 --- a/packages/mcp-toolkit/src/events/upstash.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * The Upstash backends for events: `RedisSubscriptionStore` for the subscriptions and - * `QStashDelivery` for durable, retried webhook deliveries. Needs `@upstash/redis` and - * `@upstash/qstash` installed. - */ -export { - RedisSubscriptionStore, - QStashDelivery, - DEFAULT_EVENTS_PREFIX, - type RedisSubscriptionStoreConfig, - type QStashDeliveryConfig, -} from "./backends/qstash.js"; diff --git a/packages/mcp-toolkit/tsup.config.ts b/packages/mcp-toolkit/tsup.config.ts index 6b56394..bd01bce 100644 --- a/packages/mcp-toolkit/tsup.config.ts +++ b/packages/mcp-toolkit/tsup.config.ts @@ -5,7 +5,6 @@ export default defineConfig({ "tasks/index": "src/tasks/index.ts", "tasks/upstash": "src/tasks/upstash.ts", "events/index": "src/events/index.ts", - "events/upstash": "src/events/upstash.ts", }, format: ["esm"], dts: true, From 869a1c1e1b25b27f0f585c4aa63cf3f9d3a14c1f Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 08:34:15 +0000 Subject: [PATCH 17/33] DX-3022: demo: separate tasks and events into two servers Report Desk (/api/mcp) is tasks only: generate_report with a canned multi-line report, task_status and task_cancel. Deploy Watch (/api/deploy-watch) is events only: a deploy.finished event filterable by service and environment, a list_recent_deploys tool, and a route that reports a deploy. The smoke test checks that a staging deploy is filtered out and a production deploy arrives signed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd --- CLAUDE.md | 4 +- README.md | 2 +- examples/mcp-toolkit-demo/README.md | 22 +++- .../app/api/deploy-watch/deploys/route.ts | 27 +++++ .../app/api/deploy-watch/events/route.ts | 10 ++ .../app/api/deploy-watch/route.ts | 11 ++ .../mcp-toolkit-demo/app/api/events/route.ts | 10 -- .../mcp-toolkit-demo/app/lib/deploy-watch.ts | 101 ++++++++++++++++++ examples/mcp-toolkit-demo/app/lib/events.ts | 27 ----- .../mcp-toolkit-demo/app/lib/qstash-server.ts | 24 +++-- examples/mcp-toolkit-demo/scripts/smoke.mjs | 72 ++++++++----- 11 files changed, 230 insertions(+), 80 deletions(-) create mode 100644 examples/mcp-toolkit-demo/app/api/deploy-watch/deploys/route.ts create mode 100644 examples/mcp-toolkit-demo/app/api/deploy-watch/events/route.ts create mode 100644 examples/mcp-toolkit-demo/app/api/deploy-watch/route.ts delete mode 100644 examples/mcp-toolkit-demo/app/api/events/route.ts create mode 100644 examples/mcp-toolkit-demo/app/lib/deploy-watch.ts delete mode 100644 examples/mcp-toolkit-demo/app/lib/events.ts diff --git a/CLAUDE.md b/CLAUDE.md index ebcfb20..3fdf7ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -746,7 +746,9 @@ implements TanStack AI's own backend contracts (see its section below) — keep - Tests: `src/{tasks,events}/core.test.ts` drive a real `McpServer` through `createMcpHandler` over genuine JSON-RPC; `src/*/backends/qstash.test.ts` hit real Redis. All run under the root vitest config. The demo's `pnpm smoke` is the end-to-end check (QStash dev server + both servers + the - `task.finished` webhook into the demo's receiver). + filtered `deploy.finished` webhook from the Deploy Watch server into the demo's receiver). The demo + keeps tasks (Report Desk, `/api/mcp`) and events (Deploy Watch, `/api/deploy-watch`) in separate + servers on purpose; `task.finished` is covered by unit tests only. - **Events facts.** Wire format follows ChatGPT's MCP Events (webhook only): `events/list|subscribe| unsubscribe` registered via `server.server.setRequestHandler(method, { params }, handler)` and `registerCapabilities({ events: {} } as never)` (the SDK has no events types). Subscription id = diff --git a/README.md b/README.md index 429ab5f..7453293 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Runnable demos (real Upstash Redis + a mock/real model) live in [`examples/`](./ `@upstash/agentkit-eve-extension`), [`tanstack-ai-demo`](./examples/tanstack-ai-demo) (a TanStack AI chat whose persistence, resumable stream and memory work across server instances, with a two-instance E2E suite), and [`mcp-toolkit-demo`](./examples/mcp-toolkit-demo) (an MCP server whose -long-running tool returns a task id the model polls, plus a `task.finished` event, with the client's +long-running tool returns a task id the model polls, plus a separate events server that fires `deploy.finished`, with the client's wire log on screen). ## Development diff --git a/examples/mcp-toolkit-demo/README.md b/examples/mcp-toolkit-demo/README.md index e82bbdb..d1248b6 100644 --- a/examples/mcp-toolkit-demo/README.md +++ b/examples/mcp-toolkit-demo/README.md @@ -16,8 +16,10 @@ no capabilities at all. It speaks raw stateless JSON-RPC and shows every frame i | `app/lib/workflow-server.ts` | Server two: the same tool on Upstash Workflow, one invocation per step | | `app/api/mcp/route.ts`, `app/api/mcp-workflow/route.ts` | The MCP endpoints — the SDK's own `createMcpHandler`, unchanged | | `app/api/execute/route.ts`, `app/api/execute-workflow/route.ts` | Where the work is delivered. One line each: the dispatcher owns the endpoint | -| `app/lib/events.ts` | MCP Events: Redis subscriptions, QStash delivery, and the `task.finished` event | -| `app/api/events/route.ts` | Where QStash delivers each event. One line: the delivery owns the endpoint | +| `app/lib/deploy-watch.ts` | **Deploy Watch**, the events server: the `deploy.finished` event and a `list_recent_deploys` tool | +| `app/api/deploy-watch/route.ts` | Deploy Watch's MCP endpoint | +| `app/api/deploy-watch/events/route.ts` | Where QStash delivers each event. One line: the delivery owns the endpoint | +| `app/api/deploy-watch/deploys/route.ts` | Report a deploy (stands in for your CI), which fires `deploy.finished` | | `app/api/receiver/route.ts` | A stand-in for the host's webhook receiver, so events can be seen without a public URL | | `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 | @@ -44,10 +46,24 @@ 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, unknown task id, task.finished webhook +pnpm smoke # tasks: happy path, cancel, unknown id · events: a filtered deploy.finished webhook MCP_PATH=/api/mcp-workflow pnpm smoke # the same against the Workflow server ``` +## Two separate tests + +The demo serves two unrelated MCP servers, so tasks and events can be tried on their own: + +| | Report Desk (tasks) | Deploy Watch (events) | +| --- | --- | --- | +| Endpoint | `/api/mcp` | `/api/deploy-watch` | +| What it has | `generate_report` + `task_status` / `task_cancel` | the `deploy.finished` event + `list_recent_deploys` | +| Works in | every MCP client | hosts that support MCP Events (ChatGPT) | +| Try it | ask the agent for a report; it polls until done | subscribe in a ChatGPT Work chat, then `POST /api/deploy-watch/deploys` | + +For ChatGPT, `APP_URL` stays `http://127.0.0.1:3000` (QStash runs next to the app), and the app +needs a public URL for ChatGPT to reach the MCP endpoint, e.g. a tunnel or an Upstash Box preview. + ## Two transports The demo runs two servers side by side, picked in the UI. They expose the same tools; only diff --git a/examples/mcp-toolkit-demo/app/api/deploy-watch/deploys/route.ts b/examples/mcp-toolkit-demo/app/api/deploy-watch/deploys/route.ts new file mode 100644 index 0000000..b0d1069 --- /dev/null +++ b/examples/mcp-toolkit-demo/app/api/deploy-watch/deploys/route.ts @@ -0,0 +1,27 @@ +/** + * Report a deploy, which fires `deploy.finished`. This stands in for your CI telling your server a + * deploy finished. POST a partial deploy; anything left out gets a demo value: + * + * curl -X POST $APP/api/deploy-watch/deploys -d '{"service":"checkout-api","status":"failed"}' + */ +import { randomBytes } from "node:crypto"; +import { Deploy, reportDeploy } from "../../../lib/deploy-watch"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + const input = (await request.json().catch(() => ({}))) as Partial; + const deploy = Deploy.parse({ + id: randomBytes(4).toString("hex"), + service: "checkout-api", + environment: "production", + status: "succeeded", + commit: "Retry card payments once on gateway timeouts", + author: "arda", + durationSeconds: 94, + url: "https://example.com/deploys", + ...input, + }); + const result = await reportDeploy(deploy); + return Response.json({ deploy, ...result }); +} diff --git a/examples/mcp-toolkit-demo/app/api/deploy-watch/events/route.ts b/examples/mcp-toolkit-demo/app/api/deploy-watch/events/route.ts new file mode 100644 index 0000000..798a3b7 --- /dev/null +++ b/examples/mcp-toolkit-demo/app/api/deploy-watch/events/route.ts @@ -0,0 +1,10 @@ +/** + * Where QStash delivers each `deploy.finished` event. The handler verifies QStash's signature, signs + * the envelope with the subscriber's secret, POSTs it, and answers 500 when the host's callback + * failed so QStash retries with backoff. + */ +import { events } from "../../../lib/deploy-watch"; + +export const dynamic = "force-dynamic"; + +export const POST = events.createDeliveryHandler(); diff --git a/examples/mcp-toolkit-demo/app/api/deploy-watch/route.ts b/examples/mcp-toolkit-demo/app/api/deploy-watch/route.ts new file mode 100644 index 0000000..628cf17 --- /dev/null +++ b/examples/mcp-toolkit-demo/app/api/deploy-watch/route.ts @@ -0,0 +1,11 @@ +/** The MCP endpoint for **Deploy Watch**, the events demo. */ +import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createServer } from "../../lib/deploy-watch"; + +export const dynamic = "force-dynamic"; + +const handler = createMcpHandler(() => createServer()); + +export async function POST(request: Request): Promise { + return handler.fetch(request); +} diff --git a/examples/mcp-toolkit-demo/app/api/events/route.ts b/examples/mcp-toolkit-demo/app/api/events/route.ts deleted file mode 100644 index 297d15b..0000000 --- a/examples/mcp-toolkit-demo/app/api/events/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Where QStash delivers each event. The handler verifies QStash's signature, signs the envelope - * with the subscriber's secret, POSTs it, and answers 500 when the host's callback failed so - * QStash retries with backoff. - */ -import { events } from "../../lib/events"; - -export const dynamic = "force-dynamic"; - -export const POST = events.createDeliveryHandler(); diff --git a/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts new file mode 100644 index 0000000..1766bba --- /dev/null +++ b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts @@ -0,0 +1,101 @@ +/** + * Deploy Watch: the **events** half of the demo, with no tasks in it. + * + * One event, `deploy.finished`, fired whenever a deploy is reported to `/api/deploy-watch/deploys`. + * A host that supports MCP Events (ChatGPT today) subscribes to it, optionally filtered by service + * or environment, and gets a signed webhook for every matching deploy. One plain tool, + * `list_recent_deploys`, lets the agent look around when it wakes up. + * + * Subscriptions live in Redis with their signing secrets encrypted; each delivery is a QStash + * message to `/api/deploy-watch/events`, which signs and POSTs it to the host and lets QStash retry. + */ +import { McpServer } from "@modelcontextprotocol/server"; +import { + createEventLayer, + QStashDelivery, + RedisSubscriptionStore, +} from "@upstash/mcp-toolkit/events"; +import { Redis } from "@upstash/redis"; +import * as z from "zod"; + +const APP_URL = process.env.APP_URL ?? "http://127.0.0.1:3000"; +const RECENT_KEY = "deploy-watch:recent"; + +export const events = createEventLayer({ + store: new RedisSubscriptionStore({ prefix: "deploy-watch:events:" }), + delivery: new QStashDelivery({ url: `${APP_URL}/api/deploy-watch/events` }), + // Generate a real one with `openssl rand -base64 32`; the fallback only exists so the demo boots. + secretKey: process.env.MCP_EVENTS_SECRET_KEY ?? "mcp-toolkit-demo-only-secret-key", + // Logs what the host answers to the challenge and to every delivery. + fetch: async (input, init) => { + const response = await fetch(input, init); + const body = await response + .clone() + .text() + .catch(() => ""); + console.log(`[deploy-watch] POST ${String(input).slice(0, 80)} -> ${response.status} ${body.slice(0, 200)}`); + return response; + }, + // The demo's own receiver runs on localhost. Never set this in production: it disables the + // checks that stop a subscriber from pointing your server at internal addresses. + allowInsecureCallbacks: /^http:\/\/(127\.0\.0\.1|localhost)/.test(APP_URL), +}); + +export const Deploy = z.object({ + id: z.string(), + service: z.string(), + environment: z.enum(["production", "staging"]), + status: z.enum(["succeeded", "failed"]), + commit: z.string().describe("The commit message that was deployed"), + author: z.string(), + durationSeconds: z.number(), + url: z.string(), +}); +export type Deploy = z.infer; + +export const deployFinished = events.define("deploy.finished", { + title: "Deploy finished", + description: + "A deploy finished, successfully or not. Filter by service and/or environment, or leave both out to hear about every deploy.", + input: z.object({ + service: z.string().optional().describe("Only deploys of this service, e.g. checkout-api"), + environment: z.enum(["production", "staging"]).optional().describe("Only this environment"), + }), + payload: Deploy, +}); + +/** Records a deploy and fires `deploy.finished` to every matching subscription. */ +export async function reportDeploy(deploy: Deploy) { + const redis = Redis.fromEnv(); + await redis.lpush(RECENT_KEY, JSON.stringify(deploy)); + await redis.ltrim(RECENT_KEY, 0, 19); + // The deploy id doubles as the event id, so reporting the same deploy twice delivers once. + return deployFinished.emit(deploy, { eventId: `deploy_${deploy.id}` }); +} + +export function createServer(): McpServer { + const server = new McpServer({ name: "deploy-watch", version: "0.1.0" }); + // events/list, events/subscribe and events/unsubscribe, next to the tools. + events.register(server); + + server.registerTool( + "list_recent_deploys", + { + title: "List recent deploys", + description: "Lists the most recent deploys, newest first.", + inputSchema: z.object({ limit: z.number().int().min(1).max(20).default(5) }), + }, + async ({ limit }) => { + const deploys = (await Redis.fromEnv().lrange(RECENT_KEY, 0, limit - 1)) ?? []; + const lines = deploys.map( + (d) => `${d.status === "succeeded" ? "✅" : "❌"} ${d.service} → ${d.environment}: "${d.commit}" by ${d.author} (${d.durationSeconds}s)`, + ); + return { + content: [{ type: "text", text: lines.length ? lines.join("\n") : "No deploys yet." }], + structuredContent: { deploys }, + }; + }, + ); + + return server; +} diff --git a/examples/mcp-toolkit-demo/app/lib/events.ts b/examples/mcp-toolkit-demo/app/lib/events.ts deleted file mode 100644 index 1fe0614..0000000 --- a/examples/mcp-toolkit-demo/app/lib/events.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * MCP Events for the QStash server: one `task.finished` event, so a host that supports events - * (ChatGPT today) gets a signed webhook when a task settles instead of polling `task_status`. - * - * Subscriptions live in Redis with their signing secrets encrypted; each delivery is a QStash - * message to `/api/events`, which signs and POSTs it to the host and lets QStash retry failures. - */ -import { - createEventLayer, - QStashDelivery, - RedisSubscriptionStore, - taskFinishedEvent, -} from "@upstash/mcp-toolkit/events"; - -const APP_URL = process.env.APP_URL ?? "http://127.0.0.1:3000"; - -export const events = createEventLayer({ - store: new RedisSubscriptionStore({ prefix: "mcp:events:" }), - delivery: new QStashDelivery({ url: `${APP_URL}/api/events` }), - // Generate a real one with `openssl rand -base64 32`; the fallback only exists so the demo boots. - secretKey: process.env.MCP_EVENTS_SECRET_KEY ?? "mcp-toolkit-demo-only-secret-key", - // The demo's own receiver runs on localhost. Never set this in production: it disables the - // checks that stop a subscriber from pointing your server at internal addresses. - allowInsecureCallbacks: /^http:\/\/(127\.0\.0\.1|localhost)/.test(APP_URL), -}); - -export const taskFinished = taskFinishedEvent(events); diff --git a/examples/mcp-toolkit-demo/app/lib/qstash-server.ts b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts index b2248fc..6332d4a 100644 --- a/examples/mcp-toolkit-demo/app/lib/qstash-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts @@ -11,7 +11,6 @@ import { McpServer } from "@modelcontextprotocol/server"; import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; import * as z from "zod"; -import { events, taskFinished } from "./events"; /** 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`; @@ -28,18 +27,15 @@ export const tasks = createTaskLayer({ store: new RedisTaskStore({ prefix: "mcp:task:qstash:" }), dispatcher, defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, - // Fires the `task.finished` event once per task, so event-capable hosts can skip polling. - onSettle: taskFinished.onSettle, }); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -const STEPS = 4; +const STEP_NAMES = ["gathering sources", "reading", "outlining", "writing"]; +const STEPS = STEP_NAMES.length; export function createServer(): McpServer { - const server = new McpServer({ name: "mcp-toolkit-demo-qstash", version: "0.1.0" }); - // events/list, events/subscribe, events/unsubscribe on the same endpoint as the tools. - events.register(server); + const server = new McpServer({ name: "report-desk", version: "0.1.0" }); tasks.registerTask( server, @@ -58,15 +54,23 @@ export function createServer(): McpServer { console.log(`[qstash] task=${task.taskId} cancelled before step ${step}`); return {}; } - await task.update(`Step ${step}/${STEPS}: processing ${topic}`); + await task.update(`Step ${step}/${STEPS}: ${STEP_NAMES[step - 1]}`); // 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); } + // A canned report: the demo is about the task lifecycle, not the writing. + const report = [ + `# ${topic}`, + `Sources reviewed: 12 (4 primary, 8 secondary).`, + `Key finding: interest in ${topic} grew steadily over the last three years.`, + `Open question: which of the competing explanations holds up under more data.`, + `Recommendation: run a small follow-up study before committing budget.`, + ].join("\n"); return { - content: [{ type: "text", text: `Report complete: ${topic}` }], - structuredContent: { report: `A concise report about ${topic}.` }, + content: [{ type: "text", text: report }], + structuredContent: { topic, sources: 12, report }, }; }, ); diff --git a/examples/mcp-toolkit-demo/scripts/smoke.mjs b/examples/mcp-toolkit-demo/scripts/smoke.mjs index a12a0d6..ab3aeeb 100644 --- a/examples/mcp-toolkit-demo/scripts/smoke.mjs +++ b/examples/mcp-toolkit-demo/scripts/smoke.mjs @@ -6,7 +6,7 @@ const PV = "2026-07-28"; const sleep = ms => new Promise(r => setTimeout(r, ms)); let id = 0; -async function rpc(method, params = {}) { +async function rpc(method, params = {}, path = ENDPOINT) { const headers = { "content-type": "application/json", accept: "application/json, text/event-stream", @@ -14,7 +14,7 @@ async function rpc(method, params = {}) { "mcp-method": method, }; if (params.name) headers["mcp-name"] = params.name; - const response = await fetch(`${BASE}${ENDPOINT}`, { + const response = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify({ @@ -89,42 +89,58 @@ console.log("unknown", JSON.stringify(unknown)); if (!unknown.isError) throw new Error("expected a tool error"); if (ENDPOINT === "/api/mcp") { - console.log("\n== 4. task.finished event =="); + // Deploy Watch: the events server. No tasks involved. + const WATCH = "/api/deploy-watch"; + console.log("\n== 4. Deploy Watch: deploy.finished =="); const { randomBytes } = await import("node:crypto"); - const listed = (await rpc("events/list")).events.map(e => e.name); + const listed = (await rpc("events/list", {}, WATCH)).events.map(e => e.name); console.log("events", listed.join(", ")); - if (!listed.includes("task.finished")) throw new Error("missing task.finished event"); + if (!listed.includes("deploy.finished")) throw new Error("missing deploy.finished event"); const receiverId = randomBytes(6).toString("hex"); const receiverUrl = `${BASE}/api/receiver?id=${receiverId}`; const secret = `whsec_${randomBytes(32).toString("base64")}`; await fetch(receiverUrl, { method: "PUT", body: JSON.stringify({ secret }) }); - const subscription = await rpc("events/subscribe", { - name: "task.finished", - arguments: {}, - delivery: { mode: "webhook", url: receiverUrl, secret }, - ttlMs: 600_000, - }); - console.log("subscribed", JSON.stringify(subscription)); + // Only production deploys: the staging one below must not arrive. + const subscription = await rpc( + "events/subscribe", + { + name: "deploy.finished", + arguments: { environment: "production" }, + delivery: { mode: "webhook", url: receiverUrl, secret }, + ttlMs: 600_000, + }, + WATCH, + ); + console.log("subscribed", subscription.id); + + const report = async body => + (await fetch(`${BASE}${WATCH}/deploys`, { method: "POST", body: JSON.stringify(body) })).json(); + const staging = await report({ environment: "staging", commit: "staging only" }); + const production = await report({ environment: "production", status: "failed" }); + console.log("reported", staging.deploy.id, "(staging,", staging.matched, "matched)", production.deploy.id, "(production,", production.matched, "matched)"); - const third = await call("generate_report", { topic: "events" }); - const thirdId = third.structuredContent.taskId; - let delivery; - for (let i = 0; i < 30 && !delivery; i++) { - await sleep(1500); - const received = await (await fetch(receiverUrl)).json(); - delivery = received.find(r => r.body.name === "task.finished" && r.body.data?.taskId === thirdId); + let received = []; + for (let i = 0; i < 20; i++) { + await sleep(1000); + received = (await (await fetch(receiverUrl)).json()).filter(r => r.body.name === "deploy.finished"); + if (received.length) break; } - console.log("delivery", JSON.stringify(delivery)); - if (!delivery) throw new Error("no task.finished delivery"); - if (!delivery.valid) throw new Error("delivery signature did not verify"); - if (delivery.body.data.status !== "completed") throw new Error("expected a completed task"); + await sleep(2000); + received = (await (await fetch(receiverUrl)).json()).filter(r => r.body.name === "deploy.finished"); + console.log("delivered", JSON.stringify(received.map(r => ({ valid: r.valid, id: r.body.data.id, env: r.body.data.environment })))); + if (received.length !== 1) throw new Error(`expected exactly one delivery, got ${received.length}`); + if (!received[0].valid) throw new Error("delivery signature did not verify"); + if (received[0].body.data.id !== production.deploy.id) throw new Error("wrong deploy delivered"); - await rpc("events/unsubscribe", { - name: "task.finished", - arguments: {}, - delivery: { mode: "webhook", url: receiverUrl }, - }); + const recent = await rpc("tools/call", { name: "list_recent_deploys", arguments: { limit: 2 } }, WATCH); + console.log("recent", recent.content[0].text.split("\n").join(" | ")); + + await rpc( + "events/unsubscribe", + { name: "deploy.finished", arguments: { environment: "production" }, delivery: { mode: "webhook", url: receiverUrl } }, + WATCH, + ); } console.log("\nALL E2E CHECKS PASSED"); From 521a1b9d874ff219c3566525bc2693920df2277c Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 09:35:54 +0000 Subject: [PATCH 18/33] DX-3022: mcp-toolkit README: users and subscriptions Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK --- packages/mcp-toolkit/README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md index 0f357b1..65393a9 100644 --- a/packages/mcp-toolkit/README.md +++ b/packages/mcp-toolkit/README.md @@ -413,6 +413,20 @@ await commentCreated.emit(payload, { args: { documentId }, owner: userId, eventI For conditions exact matching cannot express, add `match: (args, payload) => boolean` to the definition. +### Users and subscriptions + +The callback URL decides **where** an event goes. The principal decides **who** may receive it. + +- **The host routes to its user.** Each subscription carries a callback URL and signing secret the + host generated for it. ChatGPT sends a unique `connectors.api.openai.com/webhook/mcp-events/` + per monitor, so posting there reaches the right user. Your server never needs to know who the host + user is. +- **Your server decides who may subscribe.** `principal(auth)` gives the owner id, which is stored on + the subscription and is part of its id. `authorize` gates each subscribe, `emit({ owner })` + delivers to one user's subscriptions, and only the owner can unsubscribe. +- **Without `principal`, every subscription is anonymous.** Deliveries still reach the right host + user, but anyone who can reach the server can subscribe to any event. + ### `task.finished`: tasks that push instead of being polled ```ts @@ -448,7 +462,8 @@ Deliveries only go to the task owner's subscriptions. As of October 2026, ChatGPT is the only widely used host that subscribes to MCP Events (webhook mode, in Work chats). Codex supports events only for OpenAI's own connectors, and Claude Code, -Cursor and OpenCode do not subscribe yet. Poll and stream delivery modes in the draft are not +Cursor and OpenCode do not subscribe yet. The demo's Deploy Watch server has been tested end to end +with ChatGPT monitors. Poll and stream delivery modes in the draft are not implemented here; `events/subscribe` refuses them. ## Reference From 1a53ab7562a7f65d9bc136912b4efca29832207c Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 11:40:10 +0000 Subject: [PATCH 19/33] DX-3022: mcp-toolkit: address the review API - tasks.define() at module scope + tasks.register(server) per request, replacing registerTask(); a cold instance serving only the execute route has every handler - principal is required on both layers and fails closed - every emit names its recipients (owner or owners); the subscription index is per (event, owner, argsKey), so a {} subscription only hears about its own user - smaller surface: TaskLayer is { define, register, createExecuteHandler, getTask, cancelTask }; EventLayer is { define, register, createDeliveryHandler, list }; dispatchers get run/fail via attach; store/client overload constructors removed Security - callback URLs: trailing dots stripped; IPv4 embedded in IPv6 (mapped, compatible, NAT64, 6to4), 198.18/15, 192.0.0/24 and documentation ranges refused - every failed verification answers the same -32015 (detail logged), and at most 4 KB of the challenge answer is read - signing keys required for QStashDispatcher, WorkflowDispatcher and QStashDelivery; Workflow gets an explicit Receiver and the public url - bad signatures and bodies answer 489 + Upstash-NonRetryable-Error (QStash retries every other non-2xx); missing keys throw instead of looking like a bad signature - no default MCP_EVENTS_SECRET_KEY, demo included Correctness - create-if-absent task creation in one Lua script; update/settle share one guarded script returning [changed, ...record]; settle returns { task, settled } - a failed dispatch settles the task failed instead of leaving it working - dispatch dedupes on dispatchKey(task) = taskId + createdAt, since QStash keeps dedup ids for 10 minutes and Workflow refuses reused run ids - handler errors are logged; failure bodies decode as UTF-8; redirects are dropped, not retried; delivery response bodies are released; ttlMs: null is kept - WebCrypto only, so the package runs on edge runtimes - QStash and Workflow clients tagged with @upstash/mcp-toolkit telemetry - shared client/auth/crypto helpers; comments cut to the essentials Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK --- .changeset/mcp-toolkit-initial.md | 10 +- CLAUDE.md | 82 ++- examples/mcp-toolkit-demo/.env.example | 4 + examples/mcp-toolkit-demo/README.md | 8 +- .../app/api/deploy-watch/deploys/route.ts | 3 + .../mcp-toolkit-demo/app/lib/deploy-watch.ts | 13 +- .../mcp-toolkit-demo/app/lib/qstash-server.ts | 21 +- .../app/lib/workflow-server.ts | 18 +- packages/mcp-toolkit/README.md | 187 ++++--- .../mcp-toolkit/src/events/backends/memory.ts | 56 +- .../src/events/backends/qstash.test.ts | 38 +- .../mcp-toolkit/src/events/backends/qstash.ts | 211 +++----- packages/mcp-toolkit/src/events/core.test.ts | 193 +++++-- packages/mcp-toolkit/src/events/core.ts | 299 +++++------ packages/mcp-toolkit/src/events/index.ts | 13 +- packages/mcp-toolkit/src/events/tasks.ts | 16 +- packages/mcp-toolkit/src/events/types.ts | 88 ++- packages/mcp-toolkit/src/events/webhooks.ts | 238 ++++---- packages/mcp-toolkit/src/shared/auth.ts | 32 ++ packages/mcp-toolkit/src/shared/clients.ts | 78 +++ packages/mcp-toolkit/src/shared/crypto.ts | 53 ++ .../mcp-toolkit/src/tasks/backends/memory.ts | 87 ++- .../src/tasks/backends/qstash.test.ts | 116 +++- .../mcp-toolkit/src/tasks/backends/qstash.ts | 506 ++++++------------ .../src/tasks/backends/workflow.test.ts | 104 ++-- .../src/tasks/backends/workflow.ts | 176 +++--- packages/mcp-toolkit/src/tasks/core.test.ts | 229 ++++++-- packages/mcp-toolkit/src/tasks/core.ts | 458 +++++++--------- packages/mcp-toolkit/src/tasks/index.ts | 14 +- packages/mcp-toolkit/src/tasks/types.ts | 190 ++----- packages/mcp-toolkit/src/tasks/upstash.ts | 11 +- packages/mcp-toolkit/src/telemetry.test.ts | 50 ++ packages/mcp-toolkit/src/telemetry.ts | 33 ++ packages/mcp-toolkit/src/test-support.ts | 56 ++ 34 files changed, 2043 insertions(+), 1648 deletions(-) create mode 100644 packages/mcp-toolkit/src/shared/auth.ts create mode 100644 packages/mcp-toolkit/src/shared/clients.ts create mode 100644 packages/mcp-toolkit/src/shared/crypto.ts create mode 100644 packages/mcp-toolkit/src/telemetry.test.ts diff --git a/.changeset/mcp-toolkit-initial.md b/.changeset/mcp-toolkit-initial.md index 81b3447..97af8da 100644 --- a/.changeset/mcp-toolkit-initial.md +++ b/.changeset/mcp-toolkit-initial.md @@ -9,15 +9,19 @@ the model polls the shared `task_status` tool for progress and the result, and c with `task_cancel`. They are ordinary MCP tools, so they work in every client today. The record lives in a `TaskStore` and the work runs behind a `TaskDispatcher`; `/tasks/upstash` provides `RedisTaskStore`, `QStashDispatcher` and `WorkflowDispatcher`, and the dispatcher owns its delivery -endpoint (`export const POST = tasks.createExecuteHandler()`). `principal` scopes tasks to their -caller, `idempotencyKey` turns a retried start into a read of the existing task, and `onSettle` +endpoint (`export const POST = tasks.createExecuteHandler()`) and always verifies QStash's +signature. Tasks are declared once at module scope with `tasks.define(...)` and attached to each +request's server with `tasks.register(server)`. A required `principal` scopes tasks to their +caller (and subscriptions to theirs), `idempotencyKey` turns a retried start into a read of the existing task, and `onSettle` fires once per finished task. `@upstash/mcp-toolkit/events` — MCP Events with webhook delivery, as ChatGPT ships it. Typed -`events.define(...)` handles with `emit(payload)`, `events/list`, `events/subscribe` and +`events.define(...)` handles with an owner-scoped `emit(payload, { owner })`, `events/list`, `events/subscribe` and `events/unsubscribe` registered on your server, a signed verification challenge, deterministic subscription ids, expiry and refresh, SSRF checks on callback URLs, and signing secrets encrypted at rest. It ships `RedisSubscriptionStore` and `QStashDelivery` in the same entry point; the latter signs each attempt with Standard Webhooks and lets QStash retry failures (`export const POST = events.createDeliveryHandler()`). `taskFinishedEvent` bridges the two, so an event-capable host can stop polling. + +Both entry points use WebCrypto only, so they run on Node and edge runtimes. diff --git a/CLAUDE.md b/CLAUDE.md index 3fdf7ea..7e686c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -682,7 +682,10 @@ implements TanStack AI's own backend contracts (see its section below) — keep - **Layout:** `src/tasks/` (`index.ts` core + `upstash.ts` backends) and `src/events/` (`index.ts` exports core *and* Redis/QStash backends — one entry point by choice), built to `dist/tasks/{index,upstash}.js` and `dist/events/index.js`. - There is no root export. Shared: `src/telemetry.ts` (tag `@upstash/mcp-toolkit`), `src/version.ts`. + There is no root export. Shared: `src/shared/{auth,clients,crypto}.ts` (principal + `authOf`, + lazy env clients + `nonRetryable`, WebCrypto helpers), `src/telemetry.ts`, `src/version.ts`. + **WebCrypto only** (no `node:crypto` / `node:net`), so it runs on edge runtimes; `signWebhook`, + `SecretBox.seal/open` and `subscriptionId` are async for that reason. - **Tools mode, not the Tasks extension (since 2026-10).** A task tool answers with an ordinary tool result (`structuredContent` = the task object, plus a text line telling the model to poll); two shared tools, `task_status` and `task_cancel`, are registered once per server. No `tasks/*` @@ -691,16 +694,51 @@ implements TanStack AI's own backend contracts (see its section below) — keep `io.modelcontextprotocol/tasks` as of 2026-10-07, and a server must not return a task to a client that did not. A native adapter can sit on the same store/dispatcher later — don't reintroduce the old `methods`/`onMissingCapability`/`-32021` machinery into the core. -- **Ownership:** `createTaskLayer({ principal })` receives `ctx.http.authInfo` (that is where the - SDK v2 puts transport auth on the handler context) and stamps `task.owner`; `task_status` / - `task_cancel` report a non-owned task as *unknown* (no existence oracle). Owners compare as strings - because Redis auto-deserialization turns numeric-looking owners into numbers. -- **Idempotency:** `registerTask(..., { idempotencyKey: (args) => string })` makes the task id - `sha256([owner, tool, key])`; a hit returns the existing task. Racing first calls both write the - same record and the dispatchers dedupe on task id (QStash `deduplicationId`, Workflow - `workflowRunId`) — acceptable, documented. -- `executeTask` on a missing/expired task returns `null` (QStash acks 200) instead of throwing — a - retry cannot fix it. +- **`define` / `register`, never one call (DX-3022 review item 1).** `tasks.define(name, config, + handler)` runs at module scope and fills the handler map; `tasks.register(server)` attaches every + defined tool + the shared tools inside the per-request factory. The old + `registerTask(server, …)` only filled the map when a server was built, so a cold instance serving + only `/api/execute` threw "No task handler" and every delivery 500'd — don't bring it back, and + don't reintroduce the top-level `createServer();` workaround. +- **Ownership (`principal` is required, both layers).** `createTaskLayer` / `createEventLayer` + throw without it. It receives `ctx.http.authInfo` (where SDK v2 puts transport auth) and stamps + `task.owner` / `subscription.owner`. Returning `undefined` = not authenticated: the call is + refused (`isError` "Not authenticated" for tools, reason `not_authenticated` for events/*) — no + anonymous mode, fail closed; a stored task without an owner answers nobody. Single-tenant servers + pass `principal: () => "local"`. `task_status` / `task_cancel` report a non-owned task as + *unknown* (no existence oracle). Owners are JSON-encoded on write, so auto-deserialization hands + them back as strings and a plain `===` compares them. The public `TaskLayer` is only + `{ define, register, createExecuteHandler, getTask, cancelTask }`; a dispatcher gets `run` / + `fail` through `attach`. Tests drive deliveries with `ManualDispatcher` / `CountingDispatcher` + from `src/test-support.ts`, not with knobs on the public classes. +- **Idempotency:** `define(..., { idempotencyKey: (args) => string })` makes the task id + `sha256([owner, tool, key])`. `TaskStore.create` is **create-if-absent** and returns the existing + task when the id is taken (one Lua script on Redis), so a keyed retry is one round trip and two + racing first calls cannot both create. +- **Signing keys required, no fail-open.** `QStashDispatcher`, `WorkflowDispatcher` and + `QStashDelivery` verify with `receiver` or a `Receiver` from the `QSTASH_*_SIGNING_KEY` env vars, + and throw on the first request when neither exists (resolved outside the verify `try`, so it is + not misreported as a 401). `WorkflowDispatcher` passes `receiver` to `serve()` explicitly because + Workflow's own default silently skips verification when the env vars are missing; the `serve` + handler is built on the first request so route modules still evaluate at build time. +- **Telemetry:** `addTelemetry` (Redis) and `addQStashTelemetry` (QStash `Client`, or a Workflow + `Client` via its `.client`) append `@upstash/mcp-toolkit@` to `Upstash-Telemetry-Sdk`, once + per client; `enableTelemetry: false` on each backend opts out. +- **Round trips:** `RedisTaskStore.create` is one Lua script (create-if-absent + `PEXPIRE`); + `update` and `settle` share one guarded-write script (terminal statuses hardcoded as JSON literals) + that returns `[changed, ...HGETALL]`. `settle` returns `{ task, settled }` (null only when the task + is missing): `settled` drives `onSettle` and dispatcher cancel, and the record comes back even when + nothing changed, so `cancelTask` is one write. `SubscriptionStore.delete` takes + `{ id, event, argsKey, owner }` so the Redis store drops record + index in one pipeline. +- **Dispatch dedupe is per record, not per task id:** `dispatchKey(task)` = + `${taskId}-${createdAt ms}`. QStash remembers a `deduplicationId` for 10 minutes and Workflow + refuses a reused `workflowRunId`, while a keyed task id comes back once its 5-minute record + expires. A failed `dispatch` settles the fresh record `failed` ("Could not be queued") and rethrows. +- **No secret defaults:** `MCP_EVENTS_SECRET_KEY` has no fallback, demo included; the event layer + resolves it on first use (not at construction, so builds without env still work) and a missing key + throws rather than dropping deliveries. +- Running a missing/expired task is a no-op (QStash acks 200) instead of throwing — a retry cannot + fix it. Handler throws are logged with the task id before the 500. - **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 `task_cancel` and the executor completing cannot clobber each other — first terminal write wins; @@ -724,8 +762,12 @@ implements TanStack AI's own backend contracts (see its section below) — keep 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 + contract: **200** ack, **500** only when the task threw, and **489 + `Upstash-NonRetryable-Error: + true`** for a bad signature or body. QStash retries *every* other non-2xx, 401 and 400 included + (checked against the QStash retry docs 2026-10-08), so 489 is the only way to stop it. The same + contract holds for `QStashDelivery`. The receiver is resolved lazily but outside the verify `try`, + on purpose: resolving at `createExecuteHandler()` would fail Next.js builds that import route + modules without env. 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 @@ -753,10 +795,16 @@ implements TanStack AI's own backend contracts (see its section below) — keep unsubscribe` registered via `server.server.setRequestHandler(method, { params }, handler)` and `registerCapabilities({ events: {} } as never)` (the SDK has no events types). Subscription id = `sub_` + sha256(canonical `[owner, url, event, args]`). Matching = exact canonical args: emit - enumerates every subset of its args (cap 8 keys) and the store looks those keys up, so a - subscription to `{}` gets everything. Secrets are AES-256-GCM sealed under `secretKey` - (`MCP_EVENTS_SECRET_KEY`); a refresh with the same secret skips the challenge. Callback errors are - `ProtocolError(-32015, msg, { reason })`; bad params are `-32602` with a `reason`. + enumerates every subset of its args (cap 8 keys) and the store looks those keys up. **Every `emit` + names `owner` or `owners`** (DX-3022 review S4) and the Redis index is per + `(event, owner, argsKey)`, so a `{}` subscription gets everything *of its own user* and nothing of + anyone else's. Secrets are AES-256-GCM sealed under `secretKey` (`MCP_EVENTS_SECRET_KEY`); a + refresh with the same secret skips the challenge. A failed challenge is always the same + `ProtocolError(-32015, "Callback URL failed verification")` with no reason or status (no network + probing); the detail is `console.warn`ed. The challenge answer is read capped at 4 KB, delivery + bodies are cancelled, and 3xx is `dropped`. `callbackUrlProblem` strips trailing dots and checks + IPv4 embedded in IPv6 (mapped, compatible, NAT64, 6to4); it does not resolve DNS. Bad params are + `-32602` with a `reason`. **QStash `deduplicationId` cannot contain `:`** (dev server answers 400) — ids are `${eventId}_${subscriptionId}` sanitized; this was caught only by the e2e smoke. The task bridge is `createTaskLayer({ onSettle })`, called only by the write that performed the diff --git a/examples/mcp-toolkit-demo/.env.example b/examples/mcp-toolkit-demo/.env.example index a8b5841..4c5eed7 100644 --- a/examples/mcp-toolkit-demo/.env.example +++ b/examples/mcp-toolkit-demo/.env.example @@ -12,6 +12,10 @@ QSTASH_TOKEN= QSTASH_CURRENT_SIGNING_KEY= QSTASH_NEXT_SIGNING_KEY= +# Encrypts the hosts' webhook signing secrets at rest (Deploy Watch). Required, no default. +# Generate one with: openssl rand -base64 32 +MCP_EVENTS_SECRET_KEY= + # Where QStash delivers a task. Must be reachable *from QStash*: the local dev server can reach # 127.0.0.1, the hosted service cannot — use your deployment URL or a tunnel there. APP_URL=http://127.0.0.1:3000 diff --git a/examples/mcp-toolkit-demo/README.md b/examples/mcp-toolkit-demo/README.md index d1248b6..cdfac8b 100644 --- a/examples/mcp-toolkit-demo/README.md +++ b/examples/mcp-toolkit-demo/README.md @@ -30,7 +30,8 @@ You need an [Upstash Redis database](https://upstash.com/start-redis). QStash yo fully offline. ```bash -cp .env.example .env.local # fill in UPSTASH_REDIS_REST_URL / _TOKEN +cp .env.example .env.local # fill in UPSTASH_REDIS_REST_URL / _TOKEN, and + # MCP_EVENTS_SECRET_KEY=$(openssl rand -base64 32) pnpm qstash # terminal 1 — prints the QStash URL, token and signing keys # paste those four into .env.local @@ -111,5 +112,6 @@ get dead-lettered, it is in the QStash DLQ, not lost. - All three tools are ordinary MCP tools, so this works in every client today — Claude Code, Codex, Cursor, OpenCode, ChatGPT — none of which declare the protocol's Tasks extension yet. -- The demo leaves tasks unscoped. A multi-user server should pass `principal` to - `createTaskLayer` so one user cannot read or cancel another's task — see the package README. +- The demo has no login, so both layers use `principal: () => "demo-user"`: every caller is the + same user. A multi-user server returns its user id from the request's auth instead, so one user + cannot read or cancel another's task or subscribe as someone else — see the package README. diff --git a/examples/mcp-toolkit-demo/app/api/deploy-watch/deploys/route.ts b/examples/mcp-toolkit-demo/app/api/deploy-watch/deploys/route.ts index b0d1069..a0a2155 100644 --- a/examples/mcp-toolkit-demo/app/api/deploy-watch/deploys/route.ts +++ b/examples/mcp-toolkit-demo/app/api/deploy-watch/deploys/route.ts @@ -3,6 +3,9 @@ * deploy finished. POST a partial deploy; anything left out gets a demo value: * * curl -X POST $APP/api/deploy-watch/deploys -d '{"service":"checkout-api","status":"failed"}' + * + * Unauthenticated on purpose, so the demo is easy to drive. A real server must authenticate this + * route (your CI's token, a signed webhook): anyone who can call it can fire events. */ import { randomBytes } from "node:crypto"; import { Deploy, reportDeploy } from "../../../lib/deploy-watch"; diff --git a/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts index 1766bba..c9fcfbc 100644 --- a/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts +++ b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts @@ -20,12 +20,14 @@ import * as z from "zod"; const APP_URL = process.env.APP_URL ?? "http://127.0.0.1:3000"; const RECENT_KEY = "deploy-watch:recent"; +/** The demo has no login, so every caller is this one user. A real server returns its user id. */ +const DEMO_USER = "demo-user"; export const events = createEventLayer({ store: new RedisSubscriptionStore({ prefix: "deploy-watch:events:" }), delivery: new QStashDelivery({ url: `${APP_URL}/api/deploy-watch/events` }), - // Generate a real one with `openssl rand -base64 32`; the fallback only exists so the demo boots. - secretKey: process.env.MCP_EVENTS_SECRET_KEY ?? "mcp-toolkit-demo-only-secret-key", + // `secretKey` is read from MCP_EVENTS_SECRET_KEY (see .env.example). There is no fallback. + principal: () => DEMO_USER, // Logs what the host answers to the challenge and to every delivery. fetch: async (input, init) => { const response = await fetch(input, init); @@ -64,13 +66,16 @@ export const deployFinished = events.define("deploy.finished", { payload: Deploy, }); -/** Records a deploy and fires `deploy.finished` to every matching subscription. */ +/** + * Records a deploy and fires `deploy.finished` to the matching subscriptions of the users who may + * see it. Here that is the one demo user; a real server would pass the team's members as `owners`. + */ export async function reportDeploy(deploy: Deploy) { const redis = Redis.fromEnv(); await redis.lpush(RECENT_KEY, JSON.stringify(deploy)); await redis.ltrim(RECENT_KEY, 0, 19); // The deploy id doubles as the event id, so reporting the same deploy twice delivers once. - return deployFinished.emit(deploy, { eventId: `deploy_${deploy.id}` }); + return deployFinished.emit(deploy, { owner: DEMO_USER, eventId: `deploy_${deploy.id}` }); } export function createServer(): McpServer { diff --git a/examples/mcp-toolkit-demo/app/lib/qstash-server.ts b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts index 6332d4a..a2b5af1 100644 --- a/examples/mcp-toolkit-demo/app/lib/qstash-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts @@ -27,6 +27,10 @@ export const tasks = createTaskLayer({ store: new RedisTaskStore({ prefix: "mcp:task:qstash:" }), dispatcher, defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, + // Who is calling. The demo has no login, so every caller is the same user — said explicitly, + // because there is no anonymous default. A real server returns its user id from `auth`: + // principal: (auth) => auth?.extra?.userId as string | undefined + principal: () => "demo-user", }); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -34,11 +38,9 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const STEP_NAMES = ["gathering sources", "reading", "outlining", "writing"]; const STEPS = STEP_NAMES.length; -export function createServer(): McpServer { - const server = new McpServer({ name: "report-desk", version: "0.1.0" }); - - tasks.registerTask( - server, +// Defined at module scope, so every instance knows the handler — including one that only ever +// serves `/api/execute` and never builds an MCP server. +tasks.define( "generate_report", { title: "Generate report", @@ -75,10 +77,9 @@ export function createServer(): McpServer { }, ); +/** A fresh server per request: the defined task tool plus `task_status` and `task_cancel`. */ +export function createServer(): McpServer { + const server = new McpServer({ name: "report-desk", version: "0.1.0" }); + tasks.register(server); 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-toolkit-demo/app/lib/workflow-server.ts b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts index 5f3f645..9f9c7ea 100644 --- a/examples/mcp-toolkit-demo/app/lib/workflow-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts @@ -21,7 +21,7 @@ export const EXECUTE_URL = `${process.env.APP_URL ?? "http://127.0.0.1:3000"}/ap export const dispatcher = new WorkflowDispatcher({ url: EXECUTE_URL }); /** - * The type argument is the whole point: it flows into `registerTask`, so the handler below is + * The type argument is the whole point: it flows into `define`, 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({ @@ -29,15 +29,16 @@ export const tasks = createTaskLayer({ 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 }, + // Who is calling. The demo has no login, so every caller is the same user — said explicitly, + // because there is no anonymous default. A real server returns its user id from `auth`: + // principal: (auth) => auth?.extra?.userId as string | undefined + principal: () => "demo-user", }); const STEPS = 4; -export function createServer(): McpServer { - const server = new McpServer({ name: "mcp-toolkit-demo-workflow", version: "0.1.0" }); - - tasks.registerTask( - server, +// Module scope: the workflow endpoint needs the handler on every instance. +tasks.define( "generate_report", { title: "Generate report", @@ -69,7 +70,8 @@ export function createServer(): McpServer { }, ); +export function createServer(): McpServer { + const server = new McpServer({ name: "mcp-toolkit-demo-workflow", version: "0.1.0" }); + tasks.register(server); return server; } - -createServer(); diff --git a/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md index 65393a9..4f6345b 100644 --- a/packages/mcp-toolkit/README.md +++ b/packages/mcp-toolkit/README.md @@ -18,14 +18,16 @@ event-capable host that a task settled, so it can skip polling. npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash ``` -`@upstash/workflow` is only needed for `WorkflowDispatcher`. +`@upstash/workflow` is only needed for `WorkflowDispatcher`. The package uses WebCrypto only, so it +runs on Node and on edge runtimes. ## Tasks: long-running tools A long-running tool answers immediately with a task id instead of blocking. The model polls a shared `task_status` tool for progress and, once it completes, the result. The task record lives in Upstash Redis; the work runs through QStash or Upstash Workflow, so it survives the process that -accepted the call, and it is not bound by your function's time limit or the client's tool timeout. +accepted the call and is not bound by the client's tool-call timeout. With QStash the handler still +runs inside one function invocation; [Workflow](#choosing-a-dispatcher) lifts that limit. Everything is served as **ordinary MCP tools**, so it works in every client today — Claude Code, Codex, Cursor, OpenCode, ChatGPT — with no client capability required. See @@ -43,24 +45,24 @@ import * as z from "zod"; export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + // Who is calling: your user id from the request's auth. Required — see below. + principal: (auth) => auth?.extra?.userId as string | undefined, }); +// Module scope: every instance knows the handler, including an /api/execute instance that never +// serves an MCP request — the execute route finds a task's handler by the name stored on the task. +tasks.define( + "generate_report", + { description: "Generates a report on a topic.", inputSchema: z.object({ topic: z.string() }) }, + async ({ topic }) => ({ content: [{ type: "text", text: await writeReport(topic) }] }), +); + +// Per request: attach every defined task tool, plus task_status and task_cancel. export function createServer() { const server = new McpServer({ name: "reports", version: "1.0.0" }); - - tasks.registerTask( - server, - "generate_report", - { description: "Generates a report on a topic.", inputSchema: z.object({ topic: z.string() }) }, - async ({ topic }) => ({ content: [{ type: "text", text: await writeReport(topic) }] }), - ); - + tasks.register(server); return server; } - -// Registers the handler in every process, including an /api/execute instance that never serves an -// MCP request: the execute route finds a task's handler by the name stored on the task. -createServer(); ``` Then two routes — the MCP endpoint, which is the SDK's own handler unchanged, and the one the work @@ -119,19 +121,18 @@ running code only stops where it checks. -### Multi-user servers: set `principal` +### Who is calling: `principal` + +`principal` is required. It receives the `AuthInfo` your auth middleware attached to the request +and returns a stable caller id. Each task records it as its owner, and `task_status` / +`task_cancel` answer only for the caller who started the task — another caller's id reads exactly +like an unknown one. -Without it, anyone holding a task id can read and cancel that task. With it, each task records its -owner, and `task_status` / `task_cancel` answer only for the caller who started it — another -caller's id reads exactly like an unknown one. +There is no anonymous mode. When `principal` returns `undefined`, the call is refused with "Not +authenticated". A server with no users of its own (a local tool, a demo) says so explicitly: ```ts -const tasks = createTaskLayer({ - store: new RedisTaskStore(), - dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - // Receives the AuthInfo your auth middleware attached to the request. - principal: (auth) => auth?.extra?.userId as string | undefined, -}); +principal: () => "local", ``` Key on the _user_, not `auth.clientId`: the client id identifies the OAuth app, which is often one @@ -144,8 +145,7 @@ Agents retry tool calls, especially ones that seemed to time out. Give a task to task instead of starting another: ```ts -tasks.registerTask( - server, +tasks.define( "generate_report", { description: "Generates a report on a topic.", @@ -195,7 +195,7 @@ A queue delivery is a single serverless invocation: exceed your platform's funct 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.** +**Start on QStash. Move to Workflow when the work outgrows a function.** Three things change: ```ts import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; @@ -204,10 +204,19 @@ import type { WorkflowContext } from "@upstash/workflow"; const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + principal: (auth) => auth?.extra?.userId as string | undefined, + // The record's TTL runs from creation and is never extended. Once it passes, the record is gone + // and `isCancelled()` returns true, so give long work a longer one than the 5-minute default. + defaults: { ttlMs: 60 * 60 * 1000 }, }); ``` -The type argument flows into `registerTask`, so the handler's context becomes +1. The `createTaskLayer` type argument. +2. The work wrapped in `task.run(...)` steps. Each step is still one invocation, so keep each step + within your function limit. +3. A `ttlMs` longer than the work. + +The type argument flows into `define`, so the handler's context becomes `TaskContext & WorkflowContext` — `task.update(...)` and the engine's `task.run(...)` on one object: ```ts @@ -263,7 +272,7 @@ sequenceDiagram S->>St: create(task) Note over St: must commit before the reply —
the next poll may hit another instance St-->>S: ok - S->>D: dispatch(taskId) + S->>D: dispatch(task) D-->>S: dispatchId S->>St: update({ dispatchId }) S-->>M: taskId + "call task_status" @@ -360,8 +369,9 @@ import * as z from "zod"; export const events = createEventLayer({ store: new RedisSubscriptionStore(), delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), - secretKey: process.env.MCP_EVENTS_SECRET_KEY, // encrypts the hosts' signing secrets at rest - principal: (auth) => auth?.extra?.userId as string | undefined, + // `secretKey` defaults to MCP_EVENTS_SECRET_KEY, which encrypts the hosts' signing secrets at + // rest. There is no built-in default: generate one with `openssl rand -base64 32`. + principal: (auth) => auth?.extra?.userId as string | undefined, // required, as for tasks }); export const commentCreated = events.define("comment.created", { @@ -389,11 +399,15 @@ export const POST = events.createDeliveryHandler(); Then emit from wherever the change happens — a route, a webhook from your own app, a job: ```ts -await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); +await commentCreated.emit( + { documentId: "doc_123", commentId: "c_9", text: "Ship it?" }, + { owners: await membersOf("doc_123") }, // who may see it: required +); ``` -`emit` is typed by the `payload` schema and validates against it. It finds every live -subscription whose arguments match, and hands each one to QStash. The delivery route signs the +`emit` is typed by the `payload` schema and validates against it. It always names its recipients, +`owner` for one user or `owners` for several, and reaches only their subscriptions whose arguments +match. Each match is handed to QStash. The delivery route signs the envelope with that subscriber's secret, POSTs it, and answers 500 when the callback failed so QStash retries with backoff. Each attempt is signed fresh, and the event id stays the same, so the host can drop duplicates. @@ -403,11 +417,11 @@ host can drop duplicates. A subscription matches when every argument it gave equals the value emitted. Emitting `{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of `{ repo: "a", branch: "main" }`, and of `{}`. By default the values come from the payload fields -named in the input schema; pass `args` to set them explicitly, `owner` to deliver only to one -principal's subscriptions, and `eventId` to make a repeated emit deduplicate: +named in the input schema; pass `args` to set them explicitly, and `eventId` to make a repeated emit +deduplicate: ```ts -await commentCreated.emit(payload, { args: { documentId }, owner: userId, eventId: comment.id }); +await commentCreated.emit(payload, { owner: userId, args: { documentId }, eventId: comment.id }); ``` For conditions exact matching cannot express, add `match: (args, payload) => boolean` to the @@ -421,11 +435,14 @@ The callback URL decides **where** an event goes. The principal decides **who** host generated for it. ChatGPT sends a unique `connectors.api.openai.com/webhook/mcp-events/` per monitor, so posting there reaches the right user. Your server never needs to know who the host user is. -- **Your server decides who may subscribe.** `principal(auth)` gives the owner id, which is stored on - the subscription and is part of its id. `authorize` gates each subscribe, `emit({ owner })` - delivers to one user's subscriptions, and only the owner can unsubscribe. -- **Without `principal`, every subscription is anonymous.** Deliveries still reach the right host - user, but anyone who can reach the server can subscribe to any event. +- **Your server decides who receives what.** `principal(auth)` gives the owner id, which is stored on + the subscription and is part of its id. Every `emit` names its `owner` or `owners`, so a + subscription with no arguments still only hears about its own user's events. `authorize` adds a + per-argument check on each subscribe ("can this user see this document"), and only the owner can + unsubscribe. +- **No anonymous subscriptions.** `principal` is required, and when it returns `undefined` the + subscribe (or unsubscribe) is refused with reason `not_authenticated`, before any challenge is + sent. ### `task.finished`: tasks that push instead of being polled @@ -448,15 +465,19 @@ Deliveries only go to the task owner's subscriptions. - **The callback.** It must be `https` on a public host: `localhost`, single-label and `.internal` names, private, loopback and link-local IPs, and credentials in the URL are refused, and - redirects are never followed. Before storing a subscription the server POSTs a signed challenge - and requires it echoed back; failures answer with `-32015` and a `reason`. Set + reserved and documentation ranges (including IPv4 embedded in IPv6) are refused, trailing dots + included, and redirects are never followed. Before storing a subscription the server POSTs a + signed challenge and requires it echoed back, reading at most 4 KB of the answer. Every failure + answers the same `-32015`, so a subscriber cannot probe your network; the detail goes to your + logs. The checks do not resolve DNS, so add egress filtering in production. Set `allowInsecureCallbacks` for local development only. - **The secret.** `whsec_` plus 24–64 base64 bytes, stored AES-256-GCM encrypted under `secretKey`. A refresh with the same secret skips the challenge; a new one re-verifies. - **Authorization.** `authorize(args, { principal, auth })` runs on every subscribe and refresh. - **Lifetime.** The host's `ttlMs` is granted up to `defaults.maxTtlMs` (30 days); `refreshBefore` tells it when to subscribe again. -- **Host answers.** `410` deletes the subscription, `413` drops the event, anything else retries. +- **Host answers.** `410` deletes the subscription, `413` and redirects drop the event, anything else + retries. ### Who can subscribe today @@ -473,16 +494,18 @@ implemented here; `events/subscribe` refuses them. ```ts interface TaskStore { - create(task: Task): Promise; + /** Create-if-absent, atomically. Returns the existing task when the id is taken, else null. */ + 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; + /** Atomic, first terminal write wins. `settled` is true only for the call that made the move. */ + settle(taskId: string, patch: TerminalTaskPatch): Promise<{ task: Task; settled: boolean } | null>; } interface TaskDispatcher { - dispatch(taskId: string): Promise; + /** Dedupe on `dispatchKey(task)`: a keyed task id comes back once its record expires. */ + dispatch(task: Task): Promise; cancel(dispatchId: string): Promise; attach?(endpoints: TaskEndpoints): void; createExecuteHandler?(): (request: Request) => Promise; @@ -504,20 +527,38 @@ neither is durable, which is exactly the failure this package is about. | | | | ------------------------- | ----------------------------------------------------------------------------------------------------------- | | `store`, `dispatcher` | Required. | +| `principal` | Required. `(auth) => string \| undefined` — the caller's id; `undefined` refuses the call. | | `defaults.ttlMs` | Retention window, `null` for unlimited. Default 5 min. | | `defaults.pollIntervalMs` | Poll interval suggested to the model. Default 2s. | -| `principal` | `(auth) => string \| undefined` — scopes tasks to their caller. Set it on any multi-user server. | | `toolNames` | Rename `task_status` / `task_cancel`, e.g. to namespace them. | | `onSettle` | `(task) => void` — called once when a task completes, fails or is cancelled. Wire `taskFinishedEvent` here. | -**`registerTask` config** — `description`, `inputSchema`, plus optional `title`, `ttlMs`, +**`define(name, config, handler)` config** — `description`, `inputSchema`, plus optional `title`, `ttlMs`, `pollIntervalMs`, `queuedMessage`, `completedMessage`, `idempotencyKey`. -**`RedisTaskStore`** — `redis` (defaults to `Redis.fromEnv()`), `prefix`, `enableTelemetry`. +**`RedisTaskStore`** — `redis` (defaults to `Redis.fromEnv()`; `automaticDeserialization: false` +is not supported), `prefix`, `enableTelemetry`. + +**Data at rest.** Task `args` and `result` are stored in Redis as plain JSON for the task's TTL, +which is forever with `ttlMs: null`. Don't put secrets in tool arguments or results, or set a short +TTL. Webhook signing secrets, by contrast, are encrypted with `secretKey`. + +**`QStashDispatcher`** — `url` required; `qstash`, `receiver`, `retries`, `retryDelay`, `headers`, +`enableTelemetry`. + +**`WorkflowDispatcher`** — `url` required; `client`, `qstash`, `receiver`, `headers`, `retries`, +`enableTelemetry`. -**`QStashDispatcher`** — `url` required; `qstash`, `receiver`, `retries`, `retryDelay`, `headers`. +**Signing keys are required.** Every delivery endpoint (`QStashDispatcher`, `WorkflowDispatcher`, +`QStashDelivery`) verifies QStash's signature with `receiver`, or with a `Receiver` built from +`QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY`. With neither, the endpoint throws on +its first request; it never runs a delivery unverified. This matters most for Workflow, whose own +`serve()` skips verification when the env vars are missing. -**`WorkflowDispatcher`** — `url` required; `client`, `headers`, `retries`. +**Telemetry.** The Redis, QStash and Workflow clients the toolkit builds or receives get +`@upstash/mcp-toolkit@` appended to their `Upstash-Telemetry-Sdk` header, the same way +the other Upstash SDKs report. Opt out per backend with `enableTelemetry: false`, on the client +itself, or with `UPSTASH_DISABLE_TELEMETRY`. @@ -526,9 +567,10 @@ neither is durable, which is exactly the failure this package is about. | Export | What it is | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `createTaskLayer(options)` | `{ registerTask, executeTask, failTask, createExecuteHandler, getTask, cancelTask, store, dispatcher }` | +| `createTaskLayer(options)` | `{ define, register, createExecuteHandler, getTask, cancelTask }` | | `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 | +| `TaskEndpoints`, `TaskJournal`, `dispatchKey` | What a dispatcher calls back into, how it journals this package's writes, and the key it dedupes on | +| `SettleResult`, `PrincipalResolver` | What `settle` returns, and the type of `principal` | | `Task`, `WireTask`, `TaskStatus`, `TaskError` | The record, and the subset the model sees | | `isTerminal`, `TERMINAL_STATUSES`, `UnknownTaskError` | Status helpers and the store's error type | | `DEFAULT_TOOL_NAMES`, `CallerAuth` | `{ status: "task_status", cancel: "task_cancel" }`, and what `principal` receives | @@ -544,9 +586,10 @@ neither is durable, which is exactly the failure this package is about. interface SubscriptionStore { put(subscription: Subscription): Promise; get(id: string): Promise; - delete(id: string): Promise; - /** Every live subscription to `event` whose canonical arguments are one of `argsKeys`. */ - find(event: string, argsKeys: string[]): Promise; + /** Gets the index coordinates along with the id, so it needs no read first. */ + delete(subscription: Pick): Promise; + /** Every live subscription to `event`, owned by one of `owners`, with one of `argsKeys`. */ + find(event: string, owners: string[], argsKeys: string[]): Promise; } interface EventDelivery { @@ -557,7 +600,7 @@ interface EventDelivery { ``` `RedisSubscriptionStore` keeps one expiring key per subscription and a sorted set per -`(event, arguments)` scored by expiry, so an emit reads only what it can match. +`(event, owner, arguments)` scored by expiry, so an emit reads only the named owners' entries. `MemorySubscriptionStore` + `InlineDelivery` (sends in-process, no retries) ship for tests. @@ -570,8 +613,8 @@ interface EventDelivery { | | | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `store`, `delivery` | Required. | -| `secretKey` | Encrypts stored signing secrets. Defaults to `MCP_EVENTS_SECRET_KEY`; required. | -| `principal` | `(auth) => string \| undefined` — part of the subscription id, passed to `authorize`, used by `emit({ owner })`. | +| `secretKey` | Encrypts stored signing secrets. Defaults to `MCP_EVENTS_SECRET_KEY`; required, no built-in default. | +| `principal` | Required. `(auth) => string \| undefined` — owns each subscription; `undefined` refuses. | | `defaults.ttlMs` / `defaults.maxTtlMs` | Granted lifetime when none is asked for (7 days), and the cap (30 days). | | `allowInsecureCallbacks` | Accept `http://` and private hosts. Local development only. | | `timeoutMs` | Per-POST timeout. Default 10s. | @@ -580,7 +623,8 @@ interface EventDelivery { **`RedisSubscriptionStore`** — `redis`, `prefix` (default `mcp-events:`), `enableTelemetry`. -**`QStashDelivery`** — `url` required; `qstash`, `receiver`, `retries` (default 3), `retryDelay`, `headers`. +**`QStashDelivery`** — `url` required; `qstash`, `receiver`, `retries` (default 3), `retryDelay`, `headers`, +`enableTelemetry`. @@ -589,7 +633,7 @@ interface EventDelivery { | Export | What it is | | --------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `createEventLayer(options)` | `{ define, register, createDeliveryHandler, send, list, store, delivery }` | +| `createEventLayer(options)` | `{ define, register, createDeliveryHandler, list }` | | `taskFinishedEvent(events)` | `{ event, onSettle }` — the bridge from tasks | | `SubscriptionStore`, `EventDelivery`, `Subscription`, `EventEnvelope` | The two seams and the records | | `signWebhook`, `verifyWebhook` | Standard Webhooks signing, and verification for writing a receiver | @@ -615,8 +659,9 @@ QStash signature — against the URL you published to rather than `request.url`, 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. +terminal, **500** the handler threw so try again, and **489** with `Upstash-NonRetryable-Error` for +a bad signature or an unusable body. QStash retries every other non-2xx, and a retry cannot fix +either of those. **`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 @@ -655,9 +700,9 @@ id and the failed response attached. The message is in the QStash DLQ, not lost.
Is the task id a secret? -Without `principal`, effectively yes: ids are random (~122 bits for `randomUUID`), but anyone who -learns one can read _and cancel_ that task. With `principal` set, an id is useless to anyone but -its owner. Idempotent task ids are a hash of caller, tool and key, so they are not guessable either. +No. Every task is scoped to the `principal` that started it, so an id is useless to anyone but its +owner. Ids are also random (~122 bits for `randomUUID`), and idempotent task ids are a hash of +caller, tool and key, so they are not guessable either.
@@ -665,8 +710,8 @@ its owner. Idempotent task ids are a hash of caller, tool and key, so they are n - `input_required` — a handler asking the user something mid-task. It would be the same shape: write the question into the record, let the handler read the answer at a step boundary. -- Listing tasks. Deliberately absent: without sessions, a list is only safe once scoped by - `principal`, and the model rarely needs it. +- Listing tasks. Deliberately absent: the model rarely needs it, and a list adds an index to keep + consistent with every expiry. - A native Tasks-extension adapter. It sits on the same store; it waits on client support. - Event replay (`cursor`), and the draft's poll and stream delivery modes. Subscriptions always answer `cursor: null, truncated: false`. diff --git a/packages/mcp-toolkit/src/events/backends/memory.ts b/packages/mcp-toolkit/src/events/backends/memory.ts index 9f39470..f7a9966 100644 --- a/packages/mcp-toolkit/src/events/backends/memory.ts +++ b/packages/mcp-toolkit/src/events/backends/memory.ts @@ -1,9 +1,6 @@ /** - * Single-process backends, for tests and for a first local run. - * - * {@link MemorySubscriptionStore} forgets every subscription on restart, and {@link InlineDelivery} - * POSTs from the process that called `emit`, once, with no retries. Use the Upstash backends when - * a host has to be able to rely on the deliveries. + * Single-process backends for tests and a first local run. {@link InlineDelivery} POSTs once, + * from the process that called `emit`, with no retries. */ import type { DeliveryEndpoints, @@ -11,6 +8,7 @@ import type { EventDelivery, SendOutcome, Subscription, + SubscriptionRef, SubscriptionStore, } from "../types.js"; @@ -23,32 +21,45 @@ export class MemorySubscriptionStore implements SubscriptionStore { } async get(id: string): Promise { - const subscription = this.subscriptions.get(id); - if (!subscription) return null; - if (subscription.expiresAt <= Date.now()) { - this.subscriptions.delete(id); - return null; - } - return { ...subscription }; + const subscription = this.live(id); + return subscription ? { ...subscription } : null; } - async delete(id: string): Promise { + async delete({ id }: SubscriptionRef): Promise { this.subscriptions.delete(id); } - async find(event: string, argsKeys: string[]): Promise { + async find( + event: string, + owners: readonly string[], + argsKeys: readonly string[], + ): Promise { + const ownerSet = new Set(owners); const keys = new Set(argsKeys); - const now = Date.now(); - return [...this.subscriptions.values()] - .filter((sub) => sub.event === event && keys.has(sub.argsKey) && sub.expiresAt > now) + return [...this.subscriptions.keys()] + .map((id) => this.live(id)) + .filter( + (sub): sub is Subscription => + sub !== undefined && + sub.event === event && + ownerSet.has(sub.owner) && + keys.has(sub.argsKey), + ) .map((sub) => ({ ...sub })); } + + /** Stands in for Redis' expiry. */ + private live(id: string): Subscription | undefined { + const subscription = this.subscriptions.get(id); + if (subscription && subscription.expiresAt <= Date.now()) { + this.subscriptions.delete(id); + return undefined; + } + return subscription; + } } -/** - * Sends each delivery from the calling process, once. `emit` resolves after every POST has been - * answered. A failed delivery is reported to `onOutcome` and then forgotten. - */ +/** Sends each delivery from the calling process, once. A failure goes to `onOutcome`, then is forgotten. */ export class InlineDelivery implements EventDelivery { private endpoints: DeliveryEndpoints | undefined; @@ -62,8 +73,9 @@ export class InlineDelivery implements EventDelivery { async enqueue(jobs: DeliveryJob[]): Promise { const endpoints = this.endpoints; - if (!endpoints) + if (!endpoints) { throw new Error("InlineDelivery is not attached — pass it to createEventLayer()."); + } await Promise.all( jobs.map(async (job) => { const outcome = await endpoints.send(job); diff --git a/packages/mcp-toolkit/src/events/backends/qstash.test.ts b/packages/mcp-toolkit/src/events/backends/qstash.test.ts index b8c05c4..8497942 100644 --- a/packages/mcp-toolkit/src/events/backends/qstash.test.ts +++ b/packages/mcp-toolkit/src/events/backends/qstash.test.ts @@ -42,7 +42,7 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { const pttl = await redis.pttl(store.subKey(sub.id)); expect(pttl).toBeGreaterThan(0); expect(pttl).toBeLessThanOrEqual(30_000); - expect(await redis.pttl(store.indexKey(sub.event, sub.argsKey))).toBeGreaterThan(0); + expect(await redis.pttl(await store.indexKey(sub))).toBeGreaterThan(0); }); it("finds subscriptions by any of several argument keys, and only live ones", async () => { @@ -51,10 +51,20 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { const other = makeSub({ event: "find", args: { repo: "b" } }); for (const sub of [all, one, other]) await store.put(sub); - const found = await store.find("find", ["{}", canonicalJson({ repo: "a" })]); + const found = await store.find("find", ["123"], ["{}", canonicalJson({ repo: "a" })]); expect(found.map((s) => s.id).sort()).toEqual([all.id, one.id].sort()); - expect(await store.find("find", [])).toEqual([]); - expect(await store.find("other-event", ["{}"])).toEqual([]); + expect(await store.find("find", ["123"], [])).toEqual([]); + expect(await store.find("other-event", ["123"], ["{}"])).toEqual([]); + }); + + it("only finds the subscriptions of the owners asked for", async () => { + const alice = makeSub({ event: "owned", args: {}, owner: "alice" }); + const bob = makeSub({ event: "owned", args: {}, owner: "bob" }); + for (const sub of [alice, bob]) await store.put(sub); + expect((await store.find("owned", ["alice"], ["{}"])).map((s) => s.id)).toEqual([alice.id]); + expect(await store.find("owned", ["mallory"], ["{}"])).toEqual([]); + const both = await store.find("owned", ["alice", "bob"], ["{}"]); + expect(both.map((s) => s.id).sort()).toEqual([alice.id, bob.id].sort()); }); it("refreshing replaces the record and extends the index", async () => { @@ -62,18 +72,18 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { await store.put(sub); await store.put({ ...sub, expiresAt: Date.now() + 50_000 }); expect((await store.get(sub.id))?.expiresAt).toBeGreaterThan(Date.now() + 40_000); - expect(await store.find("refresh", [sub.argsKey])).toHaveLength(1); - expect(await redis.pttl(store.indexKey(sub.event, sub.argsKey))).toBeGreaterThan(40_000); + expect(await store.find("refresh", [sub.owner], [sub.argsKey])).toHaveLength(1); + expect(await redis.pttl(await store.indexKey(sub))).toBeGreaterThan(40_000); }); it("deletes the record and its index entry", async () => { const sub = makeSub({ event: "delete" }); await store.put(sub); - await store.delete(sub.id); + await store.delete(sub); expect(await store.get(sub.id)).toBeNull(); - expect(await store.find("delete", [sub.argsKey])).toEqual([]); - expect(await redis.zcard(store.indexKey(sub.event, sub.argsKey))).toBe(0); - await store.delete(sub.id); // idempotent + expect(await store.find("delete", [sub.owner], [sub.argsKey])).toEqual([]); + expect(await redis.zcard(await store.indexKey(sub))).toBe(0); + await store.delete(sub); // idempotent }); }); @@ -132,12 +142,14 @@ describe("QStashDelivery", () => { expect((await handler(post(job))).status).toBe(500); }); - it("rejects an unsigned or malformed delivery", async () => { + it("rejects an unsigned or malformed delivery as non-retryable", async () => { const unsigned = attached("delivered", false); - expect((await unsigned.handler(post(job))).status).toBe(401); + const refused = await unsigned.handler(post(job)); + expect(refused.status).toBe(489); + expect(refused.headers.get("Upstash-NonRetryable-Error")).toBe("true"); expect(unsigned.sent).toEqual([]); const { handler } = attached("delivered"); - expect((await handler(post({ nope: true }))).status).toBe(400); + expect((await handler(post({ nope: true }))).status).toBe(489); }); it("publishes one deduplicated message per subscription, in batches of 100", async () => { diff --git a/packages/mcp-toolkit/src/events/backends/qstash.ts b/packages/mcp-toolkit/src/events/backends/qstash.ts index d8926a4..d73947a 100644 --- a/packages/mcp-toolkit/src/events/backends/qstash.ts +++ b/packages/mcp-toolkit/src/events/backends/qstash.ts @@ -1,36 +1,41 @@ /** - * The Upstash backends for events: subscriptions in Upstash Redis, deliveries through QStash. - * - * QStash is the part that makes the webhooks dependable. Each matching subscription becomes one - * QStash message to your delivery endpoint, deduplicated per event and subscription and retried - * with backoff when the host's callback fails. The endpoint signs every attempt fresh with the - * host's secret, so a retry never carries a stale Standard Webhooks timestamp. + * The Upstash event backends: subscriptions in Redis, deliveries through QStash. Each delivery is + * one QStash message to your endpoint, which signs every attempt fresh with the host's secret. */ -import { createHash } from "node:crypto"; -import { Redis } from "@upstash/redis"; -import { Client as QStashClient, Receiver } from "@upstash/qstash"; -import { addTelemetry } from "../../telemetry.js"; +import type { Redis } from "@upstash/redis"; +import type { Client as QStashClient, Receiver } from "@upstash/qstash"; +import { + lazy, + nonRetryable, + resolveQStash, + resolveReceiver, + resolveRedis, +} from "../../shared/clients.js"; +import { sha256Hex } from "../../shared/crypto.js"; import type { DeliveryEndpoints, DeliveryJob, EventDelivery, Subscription, + SubscriptionRef, SubscriptionStore, } from "../types.js"; export const DEFAULT_EVENTS_PREFIX = "mcp-events:"; export type RedisSubscriptionStoreConfig = { - /** The Upstash Redis client. Defaults to one built from `UPSTASH_REDIS_REST_URL` / `_TOKEN`. */ + /** The Upstash Redis client. Defaults to one from `UPSTASH_REDIS_REST_URL` / `_TOKEN`. */ redis?: Redis; /** Key prefix. Defaults to `mcp-events:`. */ prefix?: string; - /** Set `false` to skip reporting the SDK version in the Redis telemetry header. */ + /** Set `false` to skip this package's tag in the telemetry header. */ enableTelemetry?: boolean; }; -// Writes the record and its index entry together, with the record's own expiry, and keeps the -// index alive exactly as long as its longest-lived member. +/** + * Writes the record with its expiry and its index entry, and keeps the index alive as long as its + * longest-lived member. ARGV: record, expiresAt, id, now. + */ const PUT_SCRIPT = `#!lua flags=allow-key-locking local ttl = tonumber(ARGV[2]) - tonumber(ARGV[4]) if ttl <= 0 then return 0 end @@ -42,37 +47,24 @@ return 1 `; /** - * One string key per subscription (expiring with it), plus a sorted set per `(event, argsKey)` - * scored by expiry. An emit reads only the index entries it can match, so cost grows with the - * number of matching subscriptions, not with the total. + * One key per subscription, expiring with it, plus a sorted set per `(event, owner, argsKey)` + * scored by expiry. An emit reads only the index entries of the owners it names. */ export class RedisSubscriptionStore implements SubscriptionStore { private readonly prefix: string; - private readonly enableTelemetry: boolean; - private readonly resolveRedis: () => Redis; - private client: Redis | undefined; - - constructor(config: RedisSubscriptionStoreConfig | Redis = {}) { - const options: RedisSubscriptionStoreConfig = isRedisClient(config) - ? { redis: config } - : config; - this.prefix = options.prefix ?? DEFAULT_EVENTS_PREFIX; - this.enableTelemetry = options.enableTelemetry ?? true; - this.resolveRedis = () => options.redis ?? redisFromEnv(); - } + private readonly redis: () => Redis; - private get redis(): Redis { - if (!this.client) { - this.client = this.resolveRedis(); - addTelemetry(this.client, { enabled: this.enableTelemetry }); - } - return this.client; + constructor(config: RedisSubscriptionStoreConfig = {}) { + this.prefix = config.prefix ?? DEFAULT_EVENTS_PREFIX; + this.redis = lazy(() => + resolveRedis("RedisSubscriptionStore", config.redis, config.enableTelemetry), + ); } async put(subscription: Subscription): Promise { - await this.redis.eval( + await this.redis().eval( PUT_SCRIPT, - [this.subKey(subscription.id), this.indexKey(subscription.event, subscription.argsKey)], + [this.subKey(subscription.id), await this.indexKey(subscription)], [ JSON.stringify(subscription), String(subscription.expiresAt), @@ -83,32 +75,34 @@ export class RedisSubscriptionStore implements SubscriptionStore { } async get(id: string): Promise { - const subscription = parse(await this.redis.get(this.subKey(id))); - return subscription && subscription.expiresAt > Date.now() ? subscription : null; + return parse(await this.redis().get(this.subKey(id))); } - async delete(id: string): Promise { - const subscription = parse(await this.redis.get(this.subKey(id))); - if (!subscription) return; - const pipeline = this.redis.pipeline(); - pipeline.del(this.subKey(id)); - pipeline.zrem(this.indexKey(subscription.event, subscription.argsKey), id); + async delete(subscription: SubscriptionRef): Promise { + const pipeline = this.redis().pipeline(); + pipeline.del(this.subKey(subscription.id)); + pipeline.zrem(await this.indexKey(subscription), subscription.id); await pipeline.exec(); } - async find(event: string, argsKeys: string[]): Promise { - if (argsKeys.length === 0) return []; + async find( + event: string, + owners: readonly string[], + argsKeys: readonly string[], + ): Promise { + if (owners.length === 0 || argsKeys.length === 0) return []; + const keys = await Promise.all( + owners.flatMap((owner) => + argsKeys.map((argsKey) => this.indexKey({ event, owner, argsKey })), + ), + ); const now = Date.now(); - const pipeline = this.redis.pipeline(); - for (const argsKey of argsKeys) { - pipeline.zrange(this.indexKey(event, argsKey), now, "+inf", { byScore: true }); - } + const pipeline = this.redis().pipeline(); + for (const key of keys) pipeline.zrange(key, now, "+inf", { byScore: true }); const ids = [...new Set((await pipeline.exec()).flat().map(String))]; if (ids.length === 0) return []; - const records = await this.redis.mget(...ids.map((id) => this.subKey(id))); - return records - .map(parse) - .filter((sub): sub is Subscription => sub !== null && sub.expiresAt > now); + const records = await this.redis().mget(...ids.map((id) => this.subKey(id))); + return records.map(parse).filter((sub): sub is Subscription => sub !== null); } /** The key a subscription is stored under. */ @@ -116,10 +110,14 @@ export class RedisSubscriptionStore implements SubscriptionStore { return `${this.prefix}sub:${id}`; } - /** The index key for one `(event, argsKey)` pair. Hashed, because arguments can be long. */ - indexKey(event: string, argsKey: string): string { - const digest = createHash("sha256").update(argsKey).digest("hex").slice(0, 24); - return `${this.prefix}idx:${event}:${digest}`; + /** The index key for one `(event, owner, argsKey)`. Hashed, because arguments can be long. */ + async indexKey({ + event, + owner, + argsKey, + }: Pick): Promise { + const digest = await sha256Hex(JSON.stringify([owner, argsKey])); + return `${this.prefix}idx:${event}:${digest.slice(0, 24)}`; } } @@ -131,57 +129,54 @@ function parse(raw: unknown): Subscription | null { } export type QStashDeliveryConfig = { - /** The public URL of your delivery endpoint, the route that serves `events.createDeliveryHandler()`. */ + /** The public URL of the route serving `events.createDeliveryHandler()`. */ url: string; - /** The QStash client. Defaults to one built from `QSTASH_TOKEN` (and `QSTASH_URL`). */ + /** The QStash client. Defaults to one from `QSTASH_TOKEN` (and `QSTASH_URL`). */ qstash?: QStashClient; - /** Verifies QStash's signature on deliveries. Defaults to the `QSTASH_*_SIGNING_KEY` env vars. */ + /** + * Verifies deliveries. Defaults to one from the `QSTASH_*_SIGNING_KEY` env vars. Required either + * way: without keys the endpoint refuses to serve. + */ receiver?: Receiver; /** Retries after the first failed attempt. Defaults to 3. */ retries?: number; - /** QStash retry delay expression, e.g. `"pow(2, retried) * 1000"`. Defaults to QStash's backoff. */ + /** QStash retry delay expression. Defaults to QStash's backoff. */ retryDelay?: string; /** Extra headers QStash forwards to your endpoint. */ headers?: Record; + /** Set `false` to skip this package's tag in the telemetry header. */ + enableTelemetry?: boolean; }; /** Every webhook delivery as a QStash message: durable, deduplicated, retried with backoff. */ export class QStashDelivery implements EventDelivery { - private readonly url: string; private readonly config: QStashDeliveryConfig; + private readonly qstash: () => QStashClient; + private readonly receiver: () => Receiver; private endpoints: DeliveryEndpoints | undefined; - private client: QStashClient | undefined; - private verifier: Receiver | undefined; constructor(config: QStashDeliveryConfig) { - this.url = config.url; this.config = config; + this.qstash = lazy(() => + resolveQStash("QStashDelivery", config.qstash, config.enableTelemetry), + ); + this.receiver = lazy(() => resolveReceiver("QStashDelivery", config.receiver)); } attach(endpoints: DeliveryEndpoints): void { this.endpoints = endpoints; } - private get qstash(): QStashClient { - if (!this.client) this.client = this.config.qstash ?? qstashFromEnv(); - return this.client; - } - - private get receiver(): Receiver { - if (!this.verifier) this.verifier = this.config.receiver ?? receiverFromEnv(); - return this.verifier; - } - async enqueue(jobs: DeliveryJob[]): Promise { for (let i = 0; i < jobs.length; i += 100) { - await this.qstash.batchJSON( + await this.qstash().batchJSON( jobs.slice(i, i + 100).map((job) => ({ - url: this.url, + url: this.config.url, body: job, retries: this.config.retries ?? 3, ...(this.config.retryDelay ? { retryDelay: this.config.retryDelay } : {}), ...(this.config.headers ? { headers: this.config.headers } : {}), - // Emitting the same event id twice must not POST twice. QStash refuses ':' in the id. + // Emitting the same event id twice must not POST twice. QStash refuses ':' in ids. deduplicationId: `${job.envelope.eventId}_${job.subscriptionId}`.replace(/[^\w.-]/g, "-"), })), ); @@ -189,36 +184,37 @@ export class QStashDelivery implements EventDelivery { } /** - * The delivery endpoint: `export const POST = events.createDeliveryHandler()`. + * The delivery endpoint. * - * - **200** — delivered, or retrying cannot help (the host answered 410 or 413, or the - * subscription is gone). - * - **500** — the callback failed; QStash retries with backoff. - * - **401** — QStash's signature did not verify. + * - **200**: delivered, or retrying cannot help (410, 413, redirect, subscription gone). + * - **500**: the callback failed; QStash retries. + * - **489** with `Upstash-NonRetryable-Error`: bad signature or body, never retried. */ createDeliveryHandler(): (request: Request) => Promise { return async (request: Request): Promise => { const endpoints = this.endpoints; - if (!endpoints) + if (!endpoints) { throw new Error("QStashDelivery is not attached — pass it to createEventLayer()."); + } + // Outside the try: missing signing keys are a configuration error, not a bad signature. + const receiver = this.receiver(); const body = await request.text(); try { - await this.receiver.verify({ + await receiver.verify({ signature: request.headers.get("upstash-signature") ?? "", body, - url: this.url, + url: this.config.url, }); } catch { - return new Response("invalid signature", { status: 401 }); + return nonRetryable("invalid signature"); } let job: DeliveryJob; try { job = JSON.parse(body) as DeliveryJob; } catch { - return new Response("malformed body", { status: 400 }); + return nonRetryable("malformed body"); } - if (!job?.subscriptionId || !job.envelope) - return new Response("malformed body", { status: 400 }); + if (!job?.subscriptionId || !job.envelope) return nonRetryable("malformed body"); const outcome = await endpoints.send(job); return outcome === "retry" ? new Response("callback failed", { status: 500 }) @@ -226,34 +222,3 @@ export class QStashDelivery implements EventDelivery { }; } } - -function isRedisClient(value: RedisSubscriptionStoreConfig | Redis): value is Redis { - return typeof (value as Redis).mget === "function"; -} - -function redisFromEnv(): Redis { - const { UPSTASH_REDIS_REST_URL: url, UPSTASH_REDIS_REST_TOKEN: token } = process.env; - if (!url || !token) { - throw new Error( - "RedisSubscriptionStore 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( - "createDeliveryHandler 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("QStashDelivery needs a client: pass `qstash`, or set QSTASH_TOKEN."); - return new QStashClient({ token, baseUrl: process.env.QSTASH_URL }); -} diff --git a/packages/mcp-toolkit/src/events/core.test.ts b/packages/mcp-toolkit/src/events/core.test.ts index 1395789..8563aca 100644 --- a/packages/mcp-toolkit/src/events/core.test.ts +++ b/packages/mcp-toolkit/src/events/core.test.ts @@ -75,10 +75,13 @@ function request(method: string, params: Record, id: number): R }); } -const as = (user?: string) => - user +/** Requests run as "alice" unless told otherwise; `null` sends no auth at all. */ +const as = (given?: string | null) => { + const user = given === undefined ? "alice" : given; + return user ? { authInfo: { token: "t", clientId: "chatgpt", scopes: [], extra: { userId: user } } } : undefined; +}; function setup(options: Partial & { respond?: Respond } = {}) { const { respond, ...layerOptions } = options; @@ -109,7 +112,11 @@ function setup(options: Partial & { respond?: Respond } = {}) }); let id = 0; - const rpc = async (method: string, params: Record = {}, user?: string) => { + const rpc = async ( + method: string, + params: Record = {}, + user?: string | null, + ) => { const response = await handler.fetch(request(method, params, ++id), as(user)); return JSON.parse(await response.text()) as JsonRpc; }; @@ -117,7 +124,7 @@ function setup(options: Partial & { respond?: Respond } = {}) const subscribe = async ( args: Record, opts: { - user?: string; + user?: string | null; url?: string; secret?: string; ttlMs?: number | null; @@ -205,22 +212,38 @@ describe("events/subscribe", () => { const { subscribe, store } = setup({ respond: () => Response.json({ challenge: "nope" }) }); const response = await subscribe({ documentId: "doc_1" }); expect(response.error?.code).toBe(-32015); - expect(response.error?.data?.reason).toBe("challenge_failed"); - expect(await store.find("comment.created", ['{"documentId":"doc_1"}'])).toHaveLength(0); + expect(await store.find("comment.created", ["alice"], ['{"documentId":"doc_1"}'])).toHaveLength( + 0, + ); }); - it("refuses with -32015 when the callback is unreachable or errors", async () => { - const down = setup({ - fetch: (async () => { - throw new TypeError("fetch failed"); - }) as typeof fetch, - }); - expect((await down.subscribe({ documentId: "d" })).error?.data?.reason).toBe("unreachable"); - const erroring = setup({ respond: () => new Response("no", { status: 500 }) }); - expect((await erroring.subscribe({ documentId: "d" })).error).toMatchObject({ - code: -32015, - data: { reason: "http_status" }, - }); + it("gives the same error whatever went wrong, so the network behind it cannot be probed", async () => { + const errors = await Promise.all( + [ + setup({ + fetch: (async () => { + throw new TypeError("fetch failed"); + }) as typeof fetch, + }), + setup({ + fetch: (async () => { + throw Object.assign(new Error("timed out"), { name: "TimeoutError" }); + }) as typeof fetch, + }), + setup({ respond: () => new Response("no", { status: 500 }) }), + setup({ respond: () => new Response(null, { status: 302 }) }), + setup({ respond: () => Response.json({ challenge: "nope" }) }), + ].map(async ({ subscribe }) => (await subscribe({ documentId: "d" })).error), + ); + for (const error of errors) { + expect(error).toEqual(errors[0]); + expect(error?.code).toBe(-32015); + } + }); + + it("reads only the start of a huge challenge response", async () => { + const { subscribe } = setup({ respond: () => new Response("x".repeat(5_000_000)) }); + expect((await subscribe({ documentId: "d" })).error?.code).toBe(-32015); }); it("validates event name, mode, arguments, secret, URL and authorization", async () => { @@ -267,7 +290,10 @@ describe("emit", () => { await subscribe({ documentId: "doc_1", author: "bob" }); await subscribe({ documentId: "doc_2" }); - const result = await commentCreated.emit({ documentId: "doc_1", author: "alice", text: "hi" }); + const result = await commentCreated.emit( + { documentId: "doc_1", author: "alice", text: "hi" }, + { owner: "alice" }, + ); expect(result.matched).toBe(2); const deliveries = receiver.deliveries(); expect(deliveries.map((d) => d.url).sort()).toEqual([all.url, byAlice.url].sort()); @@ -295,11 +321,43 @@ describe("emit", () => { expect(receiver.deliveries()[0]?.url).toBe(bob.url); }); + it("only reaches the owners it names", async () => { + const { subscribe, commentCreated, receiver } = setup(); + const alice = await subscribe({ documentId: "doc_1" }, { user: "alice" }); + const bob = await subscribe({ documentId: "doc_1" }, { user: "bob" }); + const payload = { documentId: "doc_1", author: "x", text: "y" }; + + expect((await commentCreated.emit(payload, { owner: "alice" })).matched).toBe(1); + expect(receiver.deliveries().map((d) => d.url)).toEqual([alice.url]); + expect((await commentCreated.emit(payload, { owner: "mallory" })).matched).toBe(0); + expect((await commentCreated.emit(payload, { owners: ["alice", "bob"] })).matched).toBe(2); + expect( + receiver + .deliveries() + .map((d) => d.url) + .slice(1) + .sort(), + ).toEqual([alice.url, bob.url].sort()); + expect((await commentCreated.emit(payload, { owners: [] })).matched).toBe(0); + }); + + it("refuses to emit without naming who receives it", async () => { + const { commentCreated } = setup(); + await expect( + commentCreated.emit({ documentId: "d", author: "a", text: "x" }, {} as never), + ).rejects.toThrow(/owner/); + }); + it("rejects an invalid payload and an oversized one", async () => { const { commentCreated } = setup(); - await expect(commentCreated.emit({ documentId: "d" } as never)).rejects.toThrow(); await expect( - commentCreated.emit({ documentId: "d", author: "a", text: "x".repeat(300 * 1024) }), + commentCreated.emit({ documentId: "d" } as never, { owner: "alice" }), + ).rejects.toThrow(); + await expect( + commentCreated.emit( + { documentId: "d", author: "a", text: "x".repeat(300 * 1024) }, + { owner: "alice" }, + ), ).rejects.toThrow(/limit/); }); @@ -309,16 +367,19 @@ describe("emit", () => { respond: (r) => (r.body.type === "verification" ? undefined : new Response(null, { status })), }); const sub = await subscribe({ documentId: "d" }); - await commentCreated.emit({ documentId: "d", author: "a", text: "1" }); + await commentCreated.emit({ documentId: "d", author: "a", text: "1" }, { owner: "alice" }); expect(outcomes).toEqual(["gone"]); expect(await store.get(String(sub.result?.id))).toBeNull(); status = 413; await subscribe({ documentId: "d" }); - await commentCreated.emit({ documentId: "d", author: "a", text: "2" }); + await commentCreated.emit({ documentId: "d", author: "a", text: "2" }, { owner: "alice" }); status = 503; - await commentCreated.emit({ documentId: "d", author: "a", text: "3" }); - expect(outcomes).toEqual(["gone", "dropped", "retry"]); + await commentCreated.emit({ documentId: "d", author: "a", text: "3" }, { owner: "alice" }); + // A redirect is never followed, so retrying it cannot help either. + status = 307; + await commentCreated.emit({ documentId: "d", author: "a", text: "4" }, { owner: "alice" }); + expect(outcomes).toEqual(["gone", "dropped", "retry", "dropped"]); }); it("stops delivering after unsubscribe", async () => { @@ -334,9 +395,11 @@ describe("emit", () => { "alice", ); expect(response.result).toBeDefined(); - expect((await commentCreated.emit({ documentId: "d", author: "a", text: "x" })).matched).toBe( - 0, + const after = await commentCreated.emit( + { documentId: "d", author: "a", text: "x" }, + { owner: "alice" }, ); + expect(after.matched).toBe(0); }); it("keeps secrets encrypted at rest", async () => { @@ -348,6 +411,51 @@ describe("emit", () => { }); }); +describe("principal", () => { + it("refuses to subscribe or unsubscribe a caller it cannot identify", async () => { + const { subscribe, rpc, receiver } = setup(); + const anonymous = await subscribe({ documentId: "d" }, { user: null }); + expect(anonymous.error?.data?.reason).toBe("not_authenticated"); + // Refused before the challenge: nothing was posted to the callback. + expect(receiver.received).toHaveLength(0); + + const off = await rpc( + "events/unsubscribe", + { + name: "comment.created", + arguments: { documentId: "d" }, + delivery: { mode: "webhook", url: anonymous.url }, + }, + null, + ); + expect(off.error?.data?.reason).toBe("not_authenticated"); + }); + + it("has no default secret key: a missing one fails the first subscribe", async () => { + const saved = process.env.MCP_EVENTS_SECRET_KEY; + delete process.env.MCP_EVENTS_SECRET_KEY; + try { + // Building the layer must not throw (it runs at module scope, at build time)... + const { subscribe } = setup({ secretKey: undefined }); + // ...but using it without a key does. + const result = await subscribe({ documentId: "d" }); + expect(result.error?.message).toMatch(/secretKey/); + } finally { + if (saved !== undefined) process.env.MCP_EVENTS_SECRET_KEY = saved; + } + }); + + it("is required", () => { + expect(() => + createEventLayer({ + store: new MemorySubscriptionStore(), + delivery: new InlineDelivery(), + secretKey: "k", + } as unknown as EventLayerOptions), + ).toThrow(/principal/); + }); +}); + describe("task.finished", () => { it("fires once when a task settles, only to the owner's subscriptions", async () => { const { events, subscribe, receiver } = setup(); @@ -358,14 +466,12 @@ describe("task.finished", () => { principal: (auth) => auth?.extra?.userId as string | undefined, onSettle: taskFinished.onSettle, }); + tasks.define("slow", { description: "slow", inputSchema: z.object({}) }, async () => ({ + content: [{ type: "text", text: "done" }], + })); const handler = createMcpHandler(() => { const server = new McpServer({ name: "t", version: "1.0.0" }); - tasks.registerTask( - server, - "slow", - { description: "slow", inputSchema: z.object({}) }, - async () => ({ content: [{ type: "text", text: "done" }] }), - ); + tasks.register(server); return server; }); @@ -417,11 +523,32 @@ describe("helpers", () => { "https://metadata.google.internal/x", "https://user:pw@example.com/x", "https://intranet/x", + // Trailing dots resolve like the bare name. + "https://localhost./x", + "https://foo.internal./x", + "https://x.local./x", + // IPv4 written another way, or embedded in IPv6. + "https://0x7f000001/x", + "https://[::127.0.0.1]/x", + "https://[64:ff9b::127.0.0.1]/x", + "https://[64:ff9b:1::1]/x", + "https://[2002:7f00:1::]/x", + "https://[fe80::1]/x", + "https://[fd00::1]/x", + // Reserved and documentation ranges. + "https://198.18.0.1/x", + "https://192.0.0.1/x", + "https://192.0.2.1/x", + "https://198.51.100.1/x", + "https://203.0.113.1/x", ]) { expect(callbackUrlProblem(url), url).not.toBeNull(); } expect(callbackUrlProblem("https://receiver.example.com/cb")).toBeNull(); expect(callbackUrlProblem("https://8.8.8.8/cb")).toBeNull(); + expect(callbackUrlProblem("https://receiver.example.com./cb")).toBeNull(); + expect(callbackUrlProblem("https://[2606:4700:4700::1111]/cb")).toBeNull(); + expect(callbackUrlProblem("https://[64:ff9b::8.8.8.8]/cb")).toBeNull(); expect(callbackUrlProblem("http://localhost:3000/cb", true)).toBeNull(); }); }); diff --git a/packages/mcp-toolkit/src/events/core.ts b/packages/mcp-toolkit/src/events/core.ts index cff0732..68345fa 100644 --- a/packages/mcp-toolkit/src/events/core.ts +++ b/packages/mcp-toolkit/src/events/core.ts @@ -1,19 +1,17 @@ /** - * MCP Events for servers on the official TypeScript SDK. - * - * `createEventLayer` gives you typed event definitions, the three `events/*` methods a host calls - * to subscribe, and an `emit` that fans a payload out to every matching subscription as a signed - * Standard Webhooks POST. Where subscriptions live and how deliveries are retried are the two - * seams in `types.ts`; `upstash.ts` fills them with Redis and QStash. - * - * The wire format follows the MCP Triggers & Events draft as shipped by ChatGPT: webhook delivery - * only, a signed verification challenge before the first event, deterministic subscription ids, - * and expiring subscriptions the host refreshes by subscribing again. + * MCP Events for servers on the official TypeScript SDK: typed event definitions, the three + * `events/*` methods a host calls, and an owner-scoped `emit` that delivers signed Standard + * Webhooks POSTs. Webhook delivery only, as ChatGPT ships it. */ -import { createHash, randomBytes } from "node:crypto"; import { ProtocolError, type McpServer } from "@modelcontextprotocol/server"; import * as z from "zod"; -import type { CallerAuth } from "../tasks/core.js"; +import { + authOf, + requirePrincipal, + type CallerAuth, + type PrincipalResolver, +} from "../shared/auth.js"; +import { randomBase64Url, randomHex, sha256Hex } from "../shared/crypto.js"; import type { DeliveryJob, EventDelivery, @@ -21,11 +19,11 @@ import type { SendOutcome, SubscriptionStore, } from "./types.js"; -import { SecretBox, callbackUrlProblem, decodeSecret, postSigned } from "./webhooks.js"; +import { SecretBox, callbackUrlProblem, decodeSecret, postSigned, readCapped } from "./webhooks.js"; /** The draft's cap on a delivered envelope. */ export const MAX_PAYLOAD_BYTES = 256 * 1024; -/** `CallbackEndpointError`: the callback failed verification or could not be reached. */ +/** `CallbackEndpointError`: the callback failed verification. */ export const CALLBACK_ENDPOINT_ERROR = -32015; const INVALID_PARAMS = -32602; @@ -35,35 +33,31 @@ const DEFAULT_MAX_TTL_MS = 30 * DAY_MS; const DEFAULT_TIMEOUT_MS = 10_000; /** Matching enumerates subsets of the emitted arguments, so their count is capped. */ const MAX_MATCH_KEYS = 8; +/** The challenge echo is tiny; never read more than this from a callback. */ +const MAX_CHALLENGE_RESPONSE_BYTES = 4096; export type { CallerAuth }; export type EventLayerOptions = { - /** Where subscriptions live. */ store: SubscriptionStore; - /** How deliveries get to the callback, with retries. */ delivery: EventDelivery; /** - * Encrypts the hosts' signing secrets at rest. Any string works; generate one with - * `openssl rand -base64 32`. Defaults to the `MCP_EVENTS_SECRET_KEY` env var. + * Encrypts the hosts' signing secrets at rest. Defaults to `MCP_EVENTS_SECRET_KEY`; one of the + * two is required. Generate one with `openssl rand -base64 32`. */ secretKey?: string; /** - * Who is calling, as a stable string — usually your user id. It is part of the subscription id, - * it is handed to each event's `authorize`, and `emit({ owner })` uses it to deliver only to - * that caller's subscriptions. Same contract as the tasks layer's `principal`. + * Who is calling, usually your user id. Required. Every subscription is owned by its caller, + * `undefined` refuses the subscribe, and `emit` only reaches the owners it names. */ - principal?: (auth: CallerAuth | undefined) => string | undefined; + principal: PrincipalResolver; defaults?: { /** Lifetime granted when the host does not ask for one. Defaults to 7 days. */ ttlMs?: number; - /** The most the server grants, whatever the host asks. Defaults to 30 days. */ + /** The most the server grants. Defaults to 30 days. */ maxTtlMs?: number; }; - /** - * Accept `http://` and private-network callback URLs. For local development only: it turns - * off the server-side request forgery checks. - */ + /** Accept `http://` and private callback URLs. Local development only: it disables the SSRF checks. */ allowInsecureCallbacks?: boolean; /** Timeout for each POST to a callback. Defaults to 10s. */ timeoutMs?: number; @@ -71,49 +65,43 @@ export type EventLayerOptions = { fetch?: typeof fetch; }; -/** Subscription arguments are always an object schema, so they can be matched field by field. */ +/** Subscription arguments are an object schema, so they can be matched field by field. */ export type EventInputSchema = z.ZodObject; export type EventConfig = { title?: string; - /** Tells the model and the user what the event means. */ + /** What the event means, for the model and the user. */ description: string; /** What a subscriber filters on, e.g. `z.object({ repo: z.string() })`. Defaults to none. */ input?: Input; /** The shape of `data` in every delivery. */ payload: Payload; /** - * Decides whether a caller may subscribe with these arguments, e.g. whether they can see that - * repository. Runs on every subscribe and refresh. Leave unset to allow everyone. + * Whether this caller may subscribe with these arguments, e.g. whether they can see that + * document. Runs on every subscribe and refresh. Unset allows any authenticated caller. */ authorize?: ( args: z.output, - caller: { principal?: string; auth?: CallerAuth }, + caller: { principal: string; auth?: CallerAuth }, ) => boolean | Promise; - /** - * An extra in-process filter, for conditions exact argument matching cannot express — "only - * comments longer than 100 characters". Runs per subscription on every emit. - */ + /** An extra filter for what exact argument matching cannot express. Runs per subscription. */ match?: (args: z.output, payload: z.output) => boolean; }; -export type EmitOptions = { +/** Who receives an event: one owner, or several (e.g. everyone on a document). */ +export type EmitRecipients = + | { owner: string; owners?: never } + | { owners: readonly string[]; owner?: never }; + +export type EmitOptions = EmitRecipients & { /** - * The values subscriptions are matched on. A subscription matches when every argument it gave - * equals the value here, so `{ repo: "a", branch: "main" }` reaches subscribers of - * `{ repo: "a" }`, of `{ repo: "a", branch: "main" }`, and of `{}`. - * - * Defaults to the payload fields whose names appear in the input schema. + * The values subscriptions are matched on. `{ repo: "a", branch: "main" }` reaches subscribers + * of `{ repo: "a" }`, of both, and of `{}`. Defaults to the payload fields named in the input. */ args?: Partial; - /** Deliver only to subscriptions this principal created. */ - owner?: string; - /** - * The event id, stable across retries and sent as `webhook-id`. Pass your own (e.g. the id of - * the record that changed) so that emitting the same thing twice is deduplicated. - */ + /** Stable across retries and sent as `webhook-id`. Reuse an id to deduplicate an emit. */ eventId?: string; - /** When the event happened. Defaults to now. */ + /** Defaults to now. */ timestamp?: Date; }; @@ -125,11 +113,11 @@ export type EmitResult = { export type EventHandle = { readonly name: string; - /** Validates the payload and delivers it to every matching subscription. */ - emit(payload: z.input, options?: EmitOptions>): Promise; + /** Validates the payload and delivers it to the named owners' matching subscriptions. */ + emit(payload: z.input, options: EmitOptions>): Promise; }; -/** The descriptor `events/list` returns for each event. */ +/** What `events/list` returns for each event. */ export type EventDescriptor = { name: string; title?: string; @@ -140,7 +128,7 @@ export type EventDescriptor = { }; export type EventLayer = { - /** Declares an event. Call it at module load, so every instance knows every event. */ + /** Declares an event. Call it at module scope, so every instance knows every event. */ define< Input extends EventInputSchema = z.ZodObject>, Payload extends z.ZodType = z.ZodType, @@ -148,16 +136,12 @@ export type EventLayer = { name: string, config: EventConfig, ): EventHandle; - /** Declares the `events` capability and serves `events/list`, `events/subscribe`, `events/unsubscribe`. */ + /** Declares the `events` capability and serves `events/list`, `/subscribe` and `/unsubscribe`. */ register(server: McpServer): void; - /** The transport's delivery endpoint, e.g. `export const POST = events.createDeliveryHandler()`. */ + /** The transport's delivery endpoint: `export const POST = events.createDeliveryHandler()`. */ createDeliveryHandler(): (request: Request) => Promise; - /** Sends one delivery attempt. Transports call this; exposed for custom ones. */ - send(job: DeliveryJob): Promise; /** The descriptors `events/list` returns. */ list(): EventDescriptor[]; - readonly store: SubscriptionStore; - readonly delivery: EventDelivery; }; /** Thrown by `emit` when the envelope would exceed the draft's 256 KiB cap. */ @@ -195,27 +179,35 @@ const unsubscribeParams = z.looseObject({ const listParams = z.looseObject({ cursor: z.string().nullish() }); export function createEventLayer(options: EventLayerOptions): EventLayer { - const { store, delivery, principal } = options; - const secretKey = options.secretKey ?? getEnv("MCP_EVENTS_SECRET_KEY"); - if (!secretKey) { - throw new Error( - "createEventLayer needs a secretKey (or MCP_EVENTS_SECRET_KEY) to encrypt webhook secrets at rest.", - ); - } - const box = new SecretBox(secretKey); - const ttl = { - initial: options.defaults?.ttlMs ?? DEFAULT_TTL_MS, - max: options.defaults?.maxTtlMs ?? DEFAULT_MAX_TTL_MS, + const { store, delivery } = options; + const principal = requirePrincipal(options.principal, "createEventLayer"); + // Resolved on first use, because layers are built at module scope during framework builds. + // There is no default key: a missing one fails the first subscribe or delivery. + let box: SecretBox | undefined; + const secretBox = (): SecretBox => { + if (box) return box; + const secretKey = options.secretKey ?? getEnv("MCP_EVENTS_SECRET_KEY"); + if (!secretKey) { + throw new Error( + "createEventLayer needs a secretKey (or MCP_EVENTS_SECRET_KEY) to encrypt webhook secrets at rest. Generate one with: openssl rand -base64 32", + ); + } + return (box = new SecretBox(secretKey)); }; - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - /** A secret sealed under a rotated key no longer opens; that just forces a re-verification. */ - const openOrEmpty = (sealed: string): string => { + /** A secret sealed under a rotated key no longer opens, which forces a re-verification. */ + const openOrEmpty = async (sealed: string): Promise => { + const opener = secretBox(); // a missing key throws here, outside the catch try { - return box.open(sealed); + return await opener.open(sealed); } catch { return ""; } }; + const ttl = { + initial: options.defaults?.ttlMs ?? DEFAULT_TTL_MS, + max: options.defaults?.maxTtlMs ?? DEFAULT_MAX_TTL_MS, + }; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const definitions = new Map(); function define( @@ -245,18 +237,24 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { async function emit( name: string, rawPayload: unknown, - emitOptions: EmitOptions = {}, + emitOptions: EmitOptions, ): Promise { const definition = definitions.get(name); if (!definition) throw new Error(`Unknown event "${name}"`); + const owners = + emitOptions?.owners ?? (emitOptions?.owner === undefined ? undefined : [emitOptions.owner]); + if (!owners) { + throw new Error( + `emit("${name}") needs \`owner\` or \`owners\`: events only reach the subscriptions of the users you name.`, + ); + } const payload = definition.config.payload.parse(rawPayload); const args = (emitOptions.args ?? pickArgs(definition.input, payload)) as Record< string, unknown >; - const envelope: EventEnvelope = { - eventId: emitOptions.eventId ?? `evt_${randomBytes(12).toString("hex")}`, + eventId: emitOptions.eventId ?? `evt_${randomHex(12)}`, name, timestamp: (emitOptions.timestamp ?? new Date()).toISOString(), data: payload, @@ -264,15 +262,13 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { }; const bytes = new TextEncoder().encode(JSON.stringify(envelope)).length; if (bytes > MAX_PAYLOAD_BYTES) throw new EventPayloadTooLargeError(bytes); + if (owners.length === 0) return { eventId: envelope.eventId, matched: 0 }; - const candidates = await store.find(name, matchKeys(args)); - const now = Date.now(); - const targets = candidates.filter( - (sub) => - sub.expiresAt > now && - (emitOptions.owner === undefined || String(sub.owner ?? "") === emitOptions.owner) && - (!definition.config.match || definition.config.match(sub.args as never, payload as never)), - ); + const candidates = await store.find(name, [...new Set(owners)], matchKeys(args)); + const match = definition.config.match; + const targets = match + ? candidates.filter((sub) => match(sub.args as never, payload as never)) + : candidates; if (targets.length > 0) { await delivery.enqueue(targets.map((sub) => ({ subscriptionId: sub.id, envelope }))); } @@ -282,9 +278,8 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { async function send(job: DeliveryJob): Promise { const sub = await store.get(job.subscriptionId); if (!sub) return "dropped"; - // Sealed under a key that has since rotated: undeliverable until the host refreshes. - const secret = openOrEmpty(sub.encryptedSecret); - if (!secret) return "dropped"; + const secret = await openOrEmpty(sub.encryptedSecret); + if (!secret) return "dropped"; // sealed under a rotated key: wait for the host's refresh let response: Response; try { response = await postSigned({ @@ -299,12 +294,17 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { } catch { return "retry"; } + // Release the connection: the body is never needed. + await response.body?.cancel().catch(() => undefined); if (response.ok) return "delivered"; if (response.status === 410) { - await store.delete(sub.id); + await store.delete(sub); return "gone"; } - if (response.status === 413) return "dropped"; + // Redirects are never followed, so retrying one would fail the same way. + if (response.status === 413 || (response.status >= 300 && response.status < 400)) { + return "dropped"; + } return "retry"; } @@ -322,7 +322,6 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { throw invalid(`Invalid arguments: ${parsed.error.message}`, "invalid_arguments"); } const args = parsed.data as Record; - const secret = params.delivery.secret; if (!secret || !decodeSecret(secret)) { throw invalid( @@ -336,7 +335,8 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { throw invalid(problem ?? "callback URL is missing", "invalid_callback_url"); const auth = authOf(ctx); - const owner = principal?.(auth); + const owner = principal(auth); + if (owner === undefined) throw notAuthenticated(); if ( definition.config.authorize && !(await definition.config.authorize(args, { principal: owner, auth })) @@ -344,11 +344,10 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { throw invalid("Not authorized to subscribe with these arguments", "not_authorized"); } - const id = subscriptionId(owner, url, params.name, args); + const id = await subscriptionId(owner, url, params.name, args); const existing = await store.get(id); - // A refresh with the same secret skips the challenge; a new or rotated secret proves the - // callback again, so a host cannot point someone else's URL at us. - const verified = existing !== null && openOrEmpty(existing.encryptedSecret) === secret; + // A refresh with the same secret skips the challenge; a new secret proves the callback again. + const verified = existing !== null && (await openOrEmpty(existing.encryptedSecret)) === secret; if (!verified) await verifyCallback(url, secret, id); const requested = @@ -362,8 +361,9 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { args, argsKey: canonicalJson(args), url, - encryptedSecret: verified && existing ? existing.encryptedSecret : box.seal(secret), - ...(owner === undefined ? {} : { owner }), + encryptedSecret: + verified && existing ? existing.encryptedSecret : await secretBox().seal(secret), + owner, createdAt: existing?.createdAt ?? new Date(now).toISOString(), expiresAt, }); @@ -376,47 +376,48 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { } async function unsubscribe(params: z.output, ctx: unknown) { - const definition = definitions.get(params.name); - const parsed = definition?.input.safeParse(params.arguments ?? {}); + const owner = principal(authOf(ctx)); + if (owner === undefined) throw notAuthenticated(); + const parsed = definitions.get(params.name)?.input.safeParse(params.arguments ?? {}); if (parsed?.success && params.delivery.url) { - const owner = principal?.(authOf(ctx)); const args = parsed.data as Record; - await store.delete(subscriptionId(owner, params.delivery.url, params.name, args)); + await store.delete({ + id: await subscriptionId(owner, params.delivery.url, params.name, args), + event: params.name, + argsKey: canonicalJson(args), + owner, + }); } return {}; } + /** + * Posts a signed challenge and expects it echoed back. Every failure gives the caller the same + * error: telling apart a timeout, a refused port and a status would let them map the network + * behind this server. The detail goes to the server log instead. + */ async function verifyCallback(url: string, secret: string, id: string): Promise { - const challenge = randomBytes(24).toString("base64url"); - let response: Response; + const challenge = randomBase64Url(24); + let detail: string | undefined; try { - response = await postSigned({ + const response = await postSigned({ url, secret, - webhookId: `msg_verification_${randomBytes(12).toString("hex")}`, + webhookId: `msg_verification_${randomHex(12)}`, body: JSON.stringify({ type: "verification", challenge }), subscriptionId: id, timeoutMs, fetch: options.fetch, }); + const text = await readCapped(response, MAX_CHALLENGE_RESPONSE_BYTES); + if (!response.ok) detail = `HTTP ${response.status}`; + else if (parseChallenge(text) !== challenge) detail = "challenge not echoed"; } catch (error) { - const timedOut = - error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError"); - throw callbackError( - timedOut ? "timeout" : "unreachable", - "Callback URL could not be reached", - ); - } - if (!response.ok) { - throw callbackError( - "http_status", - `Callback answered the challenge with HTTP ${response.status}`, - { status: response.status }, - ); + detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error); } - const body = (await response.json().catch(() => null)) as { challenge?: unknown } | null; - if (body?.challenge !== challenge) { - throw callbackError("challenge_failed", "Callback did not echo the verification challenge"); + if (detail !== undefined) { + console.warn(`[mcp-toolkit] callback verification failed for ${url}: ${detail}`); + throw new ProtocolError(CALLBACK_ENDPOINT_ERROR, "Callback URL failed verification"); } } @@ -424,7 +425,7 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { const low = server.server; low.registerCapabilities({ events: {} } as never); low.setRequestHandler("events/list", { params: listParams }, async () => ({ - events: [...definitions.values()].map((d) => d.descriptor), + events: list(), nextCursor: null, })); low.setRequestHandler("events/subscribe", { params: subscribeParams }, (params, ctx) => @@ -437,24 +438,16 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { function createDeliveryHandler(): (request: Request) => Promise { if (!delivery.createDeliveryHandler) { - throw new Error( - "This delivery sends in-process and has no endpoint to serve. Use QStashDelivery to expose one.", - ); + throw new Error("This delivery sends in-process and has no endpoint. Use QStashDelivery."); } return delivery.createDeliveryHandler(); } + const list = () => [...definitions.values()].map((d) => d.descriptor); + delivery.attach?.({ send }); - return { - define, - register, - createDeliveryHandler, - send, - list: () => [...definitions.values()].map((d) => d.descriptor), - store, - delivery, - }; + return { define, register, createDeliveryHandler, list }; } /** JSON with object keys sorted at every level, so equal values serialize identically. */ @@ -491,17 +484,14 @@ export function matchKeys(args: Record): string[] { return keys; } -/** Deterministic, as the draft asks: same owner, callback, event and arguments → same id. */ -export function subscriptionId( - owner: string | undefined, +/** Deterministic, as the draft asks: same owner, callback, event and arguments give the same id. */ +export async function subscriptionId( + owner: string, url: string, event: string, args: Record, -): string { - const digest = createHash("sha256") - .update(canonicalJson([owner ?? null, url, event, args])) - .digest("hex"); - return `sub_${digest.slice(0, 32)}`; +): Promise { + return `sub_${(await sha256Hex(canonicalJson([owner, url, event, args]))).slice(0, 32)}`; } function pickArgs(input: EventInputSchema, payload: unknown): Record { @@ -514,25 +504,28 @@ function pickArgs(input: EventInputSchema, payload: unknown): Record { const { $schema: _ignored, ...rest } = z.toJSONSchema(schema, { io }) as Record; return rest; } -function authOf(ctx: unknown): CallerAuth | undefined { - return (ctx as { http?: { authInfo?: CallerAuth } } | undefined)?.http?.authInfo; -} - function invalid(message: string, reason: string): ProtocolError { return new ProtocolError(INVALID_PARAMS, message, { reason }); } -function callbackError( - reason: string, - message: string, - extra: Record = {}, -): ProtocolError { - return new ProtocolError(CALLBACK_ENDPOINT_ERROR, message, { reason, ...extra }); +function notAuthenticated(): ProtocolError { + return invalid( + "Not authenticated: this server could not identify the caller", + "not_authenticated", + ); } function getEnv(name: string): string | undefined { diff --git a/packages/mcp-toolkit/src/events/index.ts b/packages/mcp-toolkit/src/events/index.ts index d9b4acd..ff351e6 100644 --- a/packages/mcp-toolkit/src/events/index.ts +++ b/packages/mcp-toolkit/src/events/index.ts @@ -1,10 +1,7 @@ /** - * `@upstash/mcp-toolkit/events` — MCP Events (webhook delivery) for servers on the official - * TypeScript SDK. - * - * One entry point: the core, the Upstash backends (`RedisSubscriptionStore`, `QStashDelivery`), - * and in-memory backends for tests. Importing it needs `@upstash/redis` and `@upstash/qstash` - * installed. + * `@upstash/mcp-toolkit/events`: MCP Events (webhook delivery) for servers on the official + * TypeScript SDK, with the Upstash backends and in-memory ones for tests. Needs `@upstash/redis` + * and `@upstash/qstash` installed. */ export { createEventLayer, @@ -16,6 +13,7 @@ export { MAX_PAYLOAD_BYTES, type CallerAuth, type EmitOptions, + type EmitRecipients, type EmitResult, type EventConfig, type EventDescriptor, @@ -25,6 +23,8 @@ export { type EventLayerOptions, } from "./core.js"; +export type { PrincipalResolver } from "../shared/auth.js"; + export type { DeliveryEndpoints, DeliveryJob, @@ -32,6 +32,7 @@ export type { EventEnvelope, SendOutcome, Subscription, + SubscriptionRef, SubscriptionStore, } from "./types.js"; diff --git a/packages/mcp-toolkit/src/events/tasks.ts b/packages/mcp-toolkit/src/events/tasks.ts index 2fbf141..cb4c594 100644 --- a/packages/mcp-toolkit/src/events/tasks.ts +++ b/packages/mcp-toolkit/src/events/tasks.ts @@ -1,15 +1,12 @@ /** - * The bridge between the two halves of the toolkit: a `task.finished` event fired when a task - * settles, so a host that supports MCP Events can stop polling `task_status`. + * A `task.finished` event, fired once when a task settles, to its owner only: * * ```ts - * const events = createEventLayer({ store, delivery }); * const taskFinished = taskFinishedEvent(events); - * const tasks = createTaskLayer({ store, dispatcher, onSettle: taskFinished.onSettle }); + * const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); * ``` * - * A host subscribes with no arguments to hear about every task the caller starts, or with a - * `taskId` for one task. Deliveries only go to subscriptions made by the task's owner. + * A host subscribes with no arguments for every task its user starts, or with a `taskId`. */ import * as z from "zod"; import type { Task } from "../tasks/types.js"; @@ -48,8 +45,9 @@ export function taskFinishedEvent( }); async function onSettle(task: Task): Promise { - if (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") + if (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") { return; + } const payload: TaskFinishedPayload = { taskId: task.taskId, status: task.status, @@ -58,10 +56,10 @@ export function taskFinishedEvent( ...(task.error ? { error: task.error } : {}), }; const emitOptions = { + owner: task.owner, args: { taskId: task.taskId }, - // The settle hook can fire twice only if a store misbehaves; a stable id makes it harmless. + // Stable, so a repeated settle hook cannot deliver twice. eventId: `evt_task_${task.taskId}`, - ...(task.owner === undefined || task.owner === null ? {} : { owner: String(task.owner) }), }; try { await event.emit(payload, emitOptions); diff --git a/packages/mcp-toolkit/src/events/types.ts b/packages/mcp-toolkit/src/events/types.ts index 1f21bbc..5288375 100644 --- a/packages/mcp-toolkit/src/events/types.ts +++ b/packages/mcp-toolkit/src/events/types.ts @@ -1,108 +1,92 @@ /** - * The storage and delivery seams of the events runtime. - * - * MCP Events says how a host subscribes and what a delivery looks like on the wire. It says - * nothing about where subscriptions live or how a webhook is retried, so those are two - * interfaces: a {@link SubscriptionStore} owns the subscription records, an {@link EventDelivery} - * owns getting a signed POST to the callback. Upstash Redis + QStash (`upstash.ts`) are one - * implementation of each; the in-memory ones (`backends/memory.ts`) are for tests and local dev. + * The two seams of the events runtime: a {@link SubscriptionStore} owns the subscriptions, an + * {@link EventDelivery} gets each signed POST to its callback, with retries. */ -/** The envelope POSTed to a callback URL, as the MCP Events draft defines it. */ +/** The envelope POSTed to a callback, as the MCP Events draft defines it. */ export type EventEnvelope = { /** Stable across retries, so the host can drop duplicates. Also sent as `webhook-id`. */ eventId: string; - /** The event name, e.g. `comment.created`. */ name: string; - /** ISO-8601, when the event happened. */ + /** ISO-8601. */ timestamp: string; - /** The payload, matching the event's `payloadSchema`. */ + /** Matches the event's `payloadSchema`. */ data: TData; - /** Replay cursor. This runtime does not replay, so it is always `null`. */ + /** Replay cursor. This runtime does not replay, so always `null`. */ cursor: string | null; }; -/** One webhook subscription, as stored. The signing secret is stored encrypted. */ +/** One webhook subscription, as stored. */ export type Subscription = { - /** Deterministic: derived from the owner, callback URL, event name and arguments. */ + /** Derived from the owner, callback URL, event name and arguments. */ id: string; - /** The event name. */ event: string; /** The validated subscription arguments. */ args: Record; - /** Canonical JSON of {@link args}; the key emits are matched on. */ + /** Canonical JSON of {@link args}, which emits are matched on. */ argsKey: string; /** The callback URL the host gave. */ url: string; /** The `whsec_` signing secret, encrypted with the layer's `secretKey`. */ encryptedSecret: string; - /** The caller that subscribed, from `EventLayerOptions.principal`. */ - owner?: string; + /** The caller that subscribed. Events only reach subscriptions of the owners they name. */ + owner: string; /** ISO-8601. */ createdAt: string; - /** Epoch milliseconds after which the subscription is gone. */ + /** Epoch milliseconds. */ expiresAt: number; }; +/** The fields that locate a subscription in a store's index. */ +export type SubscriptionRef = Pick; + /** - * Durable storage for subscriptions. - * - * Matching is by exact canonical arguments: a subscription to `{ repo: "a" }` is found under the - * key `{"repo":"a"}`. The layer computes every key an emit could match (each subset of the emitted - * arguments, including `{}`) and asks the store for all of them at once, so a store only needs an - * index from `(event, argsKey)` to live subscriptions. + * Durable storage for subscriptions, indexed by `(event, owner, argsKey)`. The layer computes + * every key an emit can match, so a store needs no matching logic of its own. */ export interface SubscriptionStore { /** Creates or replaces a subscription. Must have committed before it resolves. */ put(subscription: Subscription): Promise; - /** Returns a live subscription, or `null` when it is absent or expired. */ + /** A live subscription, or `null`. */ get(id: string): Promise; - /** Removes a subscription. A no-op when it does not exist. */ - delete(id: string): Promise; - /** Returns every live subscription to `event` whose `argsKey` is one of `argsKeys`. */ - find(event: string, argsKeys: string[]): Promise; + /** Removes a subscription and its index entry. A no-op when it does not exist. */ + delete(subscription: SubscriptionRef): Promise; + /** Every live subscription to `event` owned by one of `owners`, with one of `argsKeys`. */ + find( + event: string, + owners: readonly string[], + argsKeys: readonly string[], + ): Promise; } -/** What the transport is asked to send: one envelope to one subscription. */ +/** One envelope for one subscription. */ export type DeliveryJob = { subscriptionId: string; envelope: EventEnvelope; }; -/** The outcome of one POST to a callback, as the layer classifies it. */ +/** One POST to a callback, as the layer classifies it. */ export type SendOutcome = - /** 2xx. Done. */ + /** 2xx. */ | "delivered" - /** 410 Gone: the host dropped the subscription. The layer has deleted it; do not retry. */ + /** 410: the host dropped the subscription, and the layer deleted it. */ | "gone" - /** 413, or the subscription no longer exists: retrying cannot help. */ + /** 413, a redirect, or a subscription that no longer exists: retrying cannot help. */ | "dropped" - /** Anything else. The transport should retry. */ + /** Anything else: retry. */ | "retry"; -/** The layer's entry point, handed to a delivery transport by {@link EventDelivery.attach}. */ +/** The layer's entry point, handed to a transport by {@link EventDelivery.attach}. */ export type DeliveryEndpoints = { - /** - * Loads the subscription, signs the envelope with a fresh timestamp, POSTs it, and classifies - * the response. Call it once per attempt: Standard Webhooks signatures are timestamped, so a - * retry must be signed again rather than replayed. - */ + /** Signs the envelope fresh, POSTs it and classifies the answer. Call it once per attempt. */ send(job: DeliveryJob): Promise; }; -/** - * Durable delivery transport. - * - * The draft spec asks for retries with backoff and a stable event id across them. A queue does - * exactly that, so `QStashDelivery` enqueues one message per subscription and runs - * {@link DeliveryEndpoints.send} from its delivery endpoint. `InlineDelivery` sends in-process - * with no retries, for tests and local development. - */ export interface EventDelivery { /** Accepts the jobs durably (or sends them) before resolving. */ enqueue(jobs: DeliveryJob[]): Promise; - /** Receives the layer's entry point, once, when passed to `createEventLayer`. */ + /** Receives the layer's entry point when passed to `createEventLayer`. */ attach?(endpoints: DeliveryEndpoints): void; - /** The transport's own HTTP endpoint, when it delivers through one. */ + /** The transport's HTTP endpoint, when it delivers through one. */ createDeliveryHandler?(): (request: Request) => Promise; } diff --git a/packages/mcp-toolkit/src/events/webhooks.ts b/packages/mcp-toolkit/src/events/webhooks.ts index a97fdd6..8de2e71 100644 --- a/packages/mcp-toolkit/src/events/webhooks.ts +++ b/packages/mcp-toolkit/src/events/webhooks.ts @@ -1,43 +1,38 @@ /** - * The webhook plumbing MCP Events needs: - * - * - Standard Webhooks signing (`webhook-id`, `webhook-timestamp`, `webhook-signature`). - * - Encrypting the host's signing secret at rest (AES-256-GCM). - * - Checking a callback URL before the server ever POSTs to it. + * Webhook plumbing for MCP Events: Standard Webhooks signing, the host secret encrypted at rest, + * and the checks a callback URL must pass before the server POSTs to it. */ import { - createCipheriv, - createDecipheriv, - createHash, - createHmac, + constantTimeEqual, + fromBase64, + fromUtf8, randomBytes, - timingSafeEqual, -} from "node:crypto"; -import { isIP } from "node:net"; + sha256, + toBase64, + utf8, +} from "../shared/crypto.js"; /** The secret length the MCP Events draft allows, in decoded bytes. */ export const SECRET_MIN_BYTES = 24; export const SECRET_MAX_BYTES = 64; -/** - * Decodes a `whsec_` secret into its key bytes, or returns `null` when it is malformed or - * outside the 24–64 byte range the draft allows. - */ -export function decodeSecret(secret: unknown): Buffer | null { +/** The key bytes of a `whsec_` secret, or `null` when malformed or outside 24–64 bytes. */ +export function decodeSecret(secret: unknown): Uint8Array | null { if (typeof secret !== "string" || !secret.startsWith("whsec_")) return null; const body = secret.slice("whsec_".length); if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return null; - const bytes = Buffer.from(body, "base64"); - if (bytes.length < SECRET_MIN_BYTES || bytes.length > SECRET_MAX_BYTES) return null; - return bytes; + let bytes: Uint8Array; + try { + bytes = fromBase64(body); + } catch { + return null; + } + return bytes.length >= SECRET_MIN_BYTES && bytes.length <= SECRET_MAX_BYTES ? bytes : null; } /** - * Signs a webhook body the Standard Webhooks way: base64 HMAC-SHA256 over - * `${id}.${timestamp}.${body}`, sent as `v1,`. - * - * Returns the three headers to send. `timestampSeconds` defaults to now; every attempt should be - * signed fresh, because receivers reject stale timestamps. + * Signs a body the Standard Webhooks way: base64 HMAC-SHA256 over `${id}.${timestamp}.${body}`, + * sent as `v1,`. Sign every attempt fresh: receivers reject stale timestamps. */ export async function signWebhook( secret: string, @@ -47,20 +42,22 @@ export async function signWebhook( ): Promise> { const key = decodeSecret(secret); if (!key) throw new Error("Invalid webhook secret"); - const signature = createHmac("sha256", key) - .update(`${id}.${timestampSeconds}.${body}`) - .digest("base64"); + const hmac = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, [ + "sign", + ]); + const signature = await crypto.subtle.sign( + "HMAC", + hmac, + utf8(`${id}.${timestampSeconds}.${body}`), + ); return { "webhook-id": id, "webhook-timestamp": String(timestampSeconds), - "webhook-signature": `v1,${signature}`, + "webhook-signature": `v1,${toBase64(new Uint8Array(signature))}`, }; } -/** - * Verifies a Standard Webhooks signature, with a tolerance on the timestamp. Exported for tests - * and for anyone writing a receiver; the server side of MCP Events only signs. - */ +/** Verifies a Standard Webhooks signature, for receivers and tests. */ export async function verifyWebhook( secret: string, headers: Headers, @@ -70,69 +67,57 @@ export async function verifyWebhook( const id = headers.get("webhook-id"); const timestamp = headers.get("webhook-timestamp"); const signature = headers.get("webhook-signature"); - if (!id || !timestamp || !signature) return false; + if (!id || !timestamp || !signature || !decodeSecret(secret)) return false; const ts = Number(timestamp); if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false; - if (!decodeSecret(secret)) return false; const expected = (await signWebhook(secret, id, body, ts))["webhook-signature"]; - // Several space-separated signatures are allowed (key rotation). - return signature.split(" ").some((candidate) => safeEqual(candidate, expected)); + // Several space-separated signatures are allowed, for key rotation. + return signature.split(" ").some((candidate) => constantTimeEqual(candidate, expected)); } -function safeEqual(a: string, b: string): boolean { - const left = Buffer.from(a); - const right = Buffer.from(b); - return left.length === right.length && timingSafeEqual(left, right); -} +type AesKey = Awaited>; /** - * Encrypts and decrypts the hosts' signing secrets with a server-side key, so a leaked database - * dump cannot be used to forge events. - * - * The key can be any string (it is hashed to 32 bytes); generate one with - * `openssl rand -base64 32`. Rotating it invalidates stored subscriptions, which hosts recreate - * on their next refresh. + * Encrypts the hosts' signing secrets with a server-side key (AES-256-GCM), so a leaked database + * cannot forge events. Any string works as the key; it is hashed to 32 bytes. Rotating it makes + * stored secrets unreadable, and hosts re-verify on their next refresh. */ export class SecretBox { - private readonly key: Buffer; + private readonly key: Promise; constructor(secretKey: string) { if (!secretKey) throw new Error("SecretBox needs a non-empty secretKey"); - this.key = createHash("sha256").update(secretKey).digest(); + this.key = sha256(secretKey).then((raw) => + crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]), + ); } - seal(plaintext: string): string { + async seal(plaintext: string): Promise { const iv = randomBytes(12); - const cipher = createCipheriv("aes-256-gcm", this.key, iv); - const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); - const tag = cipher.getAuthTag(); - return `v1.${iv.toString("base64")}.${Buffer.concat([data, tag]).toString("base64")}`; + const sealed = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + await this.key, + utf8(plaintext), + ); + return `v1.${toBase64(iv)}.${toBase64(new Uint8Array(sealed))}`; } - open(sealed: string): string { + async open(sealed: string): Promise { const [version, iv, payload] = sealed.split("."); if (version !== "v1" || !iv || !payload) throw new Error("Unrecognized sealed secret"); - const bytes = Buffer.from(payload, "base64"); - const decipher = createDecipheriv("aes-256-gcm", this.key, Buffer.from(iv, "base64")); - decipher.setAuthTag(bytes.subarray(bytes.length - 16)); - return Buffer.concat([ - decipher.update(bytes.subarray(0, bytes.length - 16)), - decipher.final(), - ]).toString("utf8"); + const plain = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: fromBase64(iv) }, + await this.key, + fromBase64(payload), + ); + return fromUtf8(new Uint8Array(plain)); } } /** - * Why a callback URL was refused, or `null` when it is acceptable. - * - * The server POSTs to a URL a client chose, which is the textbook server-side request forgery - * setup. This refuses non-HTTPS URLs, credentials in the URL, and hosts that name the server's own - * network: `localhost`, single-label, `.local` / `.internal` names, and private, loopback, - * link-local and carrier-NAT IP literals. Redirects are never followed when posting. - * - * It does not resolve DNS, so a public name that resolves to a private address is not caught - * here. If your server runs next to sensitive internal services, put egress filtering in front of - * it as well. + * Why a callback URL is refused, or `null` when it is acceptable. Refuses non-HTTPS, credentials, + * and hosts on the server's own network: `localhost`, single-label, `.local` / `.internal`, and + * private or reserved IP literals. It does not resolve DNS, so add egress filtering in production. */ export function callbackUrlProblem(raw: unknown, allowInsecure = false): string | null { if (typeof raw !== "string") return "callback URL is missing"; @@ -149,9 +134,16 @@ export function callbackUrlProblem(raw: unknown, allowInsecure = false): string } if (url.protocol !== "https:") return "callback URL must use https"; if (url.username || url.password) return "callback URL must not contain credentials"; - const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase(); - const ipVersion = isIP(host); - if (ipVersion !== 0) return isPrivateAddress(host) ? "callback URL must be a public host" : null; + + // `localhost.` resolves like `localhost`, so trailing dots go before any check. + const host = url.hostname.toLowerCase().replace(/\.+$/, ""); + if (host.startsWith("[")) { + return isPrivateV6(host.slice(1, -1)) ? "callback URL must be a public host" : null; + } + // The URL parser has already normalized every IPv4 form (hex, octal, short) to dotted decimal. + if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) { + return isPrivateV4(host.split(".").map(Number)) ? "callback URL must be a public host" : null; + } if ( !host.includes(".") || host === "localhost" || @@ -164,41 +156,58 @@ export function callbackUrlProblem(raw: unknown, allowInsecure = false): string return null; } -function isPrivateAddress(ip: string): boolean { - if (isIP(ip) === 4) return isPrivateV4(ip); - const v6 = ip.toLowerCase(); - const dotted = /^(?:0*:)*:?ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v6); - if (dotted?.[1]) return isPrivateV4(dotted[1]); - // The URL parser normalizes ::ffff:127.0.0.1 to ::ffff:7f00:1. - const hex = /^(?:0*:)*:?ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(v6); - if (hex?.[1] && hex[2]) { - const hi = parseInt(hex[1], 16); - const lo = parseInt(hex[2], 16); - return isPrivateV4(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`); - } - return ( - v6 === "::" || - v6 === "::1" || - /^f[cd]/.test(v6) || // fc00::/7 unique local - /^fe[89ab]/.test(v6) // fe80::/10 link-local - ); -} - -function isPrivateV4(ip: string): boolean { - const [a = 0, b = 0] = ip.split(".").map(Number); +function isPrivateV4([a = 0, b = 0, c = 0]: number[]): boolean { return ( a === 0 || a === 10 || a === 127 || - (a === 100 && b >= 64 && b <= 127) || + (a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || - a >= 224 + (a === 192 && b === 0 && (c === 0 || c === 2)) || // IETF protocol assignments, TEST-NET-1 + (a === 198 && (b === 18 || b === 19)) || // benchmarking + (a === 198 && b === 51 && c === 100) || // TEST-NET-2 + (a === 203 && b === 0 && c === 113) || // TEST-NET-3 + a >= 224 // multicast and reserved + ); +} + +function isPrivateV6(ip: string): boolean { + const g = expandV6(ip); + if (!g) return true; // unparseable: refuse + const v4 = (hi: number, lo: number) => isPrivateV4([hi >> 8, hi & 255, lo >> 8, lo & 255]); + const zero = (from: number, to: number) => g.slice(from, to).every((x) => x === 0); + if (zero(0, 5) && g[5] === 0xffff) return v4(g[6]!, g[7]!); // IPv4-mapped + if (zero(0, 6)) return v4(g[6]!, g[7]!); // IPv4-compatible, plus :: and ::1 + if (g[0] === 0x64 && g[1] === 0xff9b) { + if (zero(2, 6)) return v4(g[6]!, g[7]!); // NAT64 64:ff9b::/96 + if (g[2] === 1) return true; // local-use NAT64 64:ff9b:1::/48 + } + if (g[0] === 0x2002) return v4(g[1]!, g[2]!); // 6to4 + return ( + (g[0]! & 0xfe00) === 0xfc00 || // unique local fc00::/7 + (g[0]! & 0xffc0) === 0xfe80 || // link-local fe80::/10 + (g[0]! & 0xff00) === 0xff00 || // multicast ff00::/8 + (g[0] === 0x2001 && g[1] === 0x0db8) // documentation 2001:db8::/32 ); } -/** POSTs a signed JSON body to a callback, without following redirects. */ +/** Expands a compressed IPv6 address (as the URL parser prints it) to eight 16-bit groups. */ +function expandV6(ip: string): number[] | null { + const halves = ip.split("::"); + if (halves.length > 2) return null; + const parse = (part: string | undefined) => + part ? part.split(":").map((x) => (/^[0-9a-f]{1,4}$/.test(x) ? parseInt(x, 16) : NaN)) : []; + const head = parse(halves[0]); + const tail = parse(halves[1]); + const missing = 8 - head.length - tail.length; + if (halves.length === 1 ? missing !== 0 : missing < 0) return null; + const groups = [...head, ...Array(Math.max(missing, 0)).fill(0), ...tail]; + return groups.some(Number.isNaN) ? null : groups; +} + +/** POSTs a signed JSON body to a callback, never following redirects. */ export async function postSigned(options: { url: string; secret: string; @@ -221,3 +230,30 @@ export async function postSigned(options: { body: options.body, }); } + +/** Reads at most `maxBytes` of a response body, then drops the rest. */ +export async function readCapped(response: Response, maxBytes: number): Promise { + const reader = response.body?.getReader(); + if (!reader) return ""; + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (size < maxBytes) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + size += value.length; + } + } finally { + await reader.cancel().catch(() => undefined); + } + const bytes = new Uint8Array(Math.min(size, maxBytes)); + let offset = 0; + for (const chunk of chunks) { + const take = chunk.subarray(0, bytes.length - offset); + bytes.set(take, offset); + offset += take.length; + if (offset >= bytes.length) break; + } + return fromUtf8(bytes); +} diff --git a/packages/mcp-toolkit/src/shared/auth.ts b/packages/mcp-toolkit/src/shared/auth.ts new file mode 100644 index 0000000..2de5162 --- /dev/null +++ b/packages/mcp-toolkit/src/shared/auth.ts @@ -0,0 +1,32 @@ +/** + * The slice of the SDK's `AuthInfo` the toolkit reads. Typed structurally so a principal resolver + * does not need to import the SDK's auth types. + */ +export type CallerAuth = { + clientId?: string; + scopes?: string[]; + extra?: Record; + [key: string]: unknown; +}; + +/** + * Maps a request's auth to a stable caller id, usually your user id. `undefined` means "not + * authenticated", and the request is refused. + */ +export type PrincipalResolver = (auth: CallerAuth | undefined) => string | undefined; + +/** The `AuthInfo` an SDK request handler context carries, if any. */ +export function authOf(context: unknown): CallerAuth | undefined { + return (context as { http?: { authInfo?: CallerAuth } } | undefined)?.http?.authInfo; +} + +/** Throws at construction when a layer is built without a principal. */ +export function requirePrincipal(principal: unknown, layer: string): PrincipalResolver { + if (typeof principal !== "function") { + throw new Error( + `${layer} needs a \`principal\`: (auth) => your user id. A server with no users of its own ` + + 'passes `principal: () => "local"`.', + ); + } + return principal as PrincipalResolver; +} diff --git a/packages/mcp-toolkit/src/shared/clients.ts b/packages/mcp-toolkit/src/shared/clients.ts new file mode 100644 index 0000000..372c1cf --- /dev/null +++ b/packages/mcp-toolkit/src/shared/clients.ts @@ -0,0 +1,78 @@ +/** + * Upstash clients from env, resolved lazily: layers are built at module scope, and frameworks + * evaluate those modules at build time without the environment. + */ +import { Redis } from "@upstash/redis"; +import { Client as QStashClient, Receiver } from "@upstash/qstash"; +import { addQStashTelemetry, addTelemetry } from "../telemetry.js"; + +/** Calls `create` once, on first access. */ +export function lazy(create: () => T): () => T { + let value: T | undefined; + let done = false; + return () => { + if (!done) { + value = create(); + done = true; + } + return value as T; + }; +} + +/** A Redis client, from config or env, tagged with this package's telemetry. */ +export function resolveRedis(owner: string, redis: Redis | undefined, telemetry = true): Redis { + const client = redis ?? redisFromEnv(owner); + addTelemetry(client, { enabled: telemetry }); + return client; +} + +/** A QStash client, from config or env, tagged with this package's telemetry. */ +export function resolveQStash( + owner: string, + qstash: QStashClient | undefined, + telemetry = true, +): QStashClient { + const client = + qstash ?? + new QStashClient({ token: requireEnv(owner, "QSTASH_TOKEN"), baseUrl: env("QSTASH_URL") }); + addQStashTelemetry(client, { enabled: telemetry }); + return client; +} + +/** A signature verifier, from config or env. Throws without keys: there is no unverified mode. */ +export function resolveReceiver(owner: string, receiver: Receiver | undefined): Receiver { + if (receiver) return receiver; + const currentSigningKey = env("QSTASH_CURRENT_SIGNING_KEY"); + const nextSigningKey = env("QSTASH_NEXT_SIGNING_KEY"); + if (!currentSigningKey || !nextSigningKey) { + throw new Error( + `${owner} needs signing keys: pass \`receiver\`, or set QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY.`, + ); + } + return new Receiver({ currentSigningKey, nextSigningKey }); +} + +function redisFromEnv(owner: string): Redis { + return new Redis({ + url: requireEnv(owner, "UPSTASH_REDIS_REST_URL"), + token: requireEnv(owner, "UPSTASH_REDIS_REST_TOKEN"), + }); +} + +export function env(name: string): string | undefined { + return typeof process === "object" ? process.env?.[name] : undefined; +} + +export function requireEnv(owner: string, name: string): string { + const value = env(name); + if (!value) throw new Error(`${owner} needs ${name} (or pass the client explicitly).`); + return value; +} + +/** + * QStash retries every non-2xx response. This status and header tell it to stop and dead-letter + * the message instead, for requests a retry cannot fix. + */ +export function nonRetryable(message: string): Response { + return new Response(message, { status: 489, headers: { "Upstash-NonRetryable-Error": "true" } }); +} diff --git a/packages/mcp-toolkit/src/shared/crypto.ts b/packages/mcp-toolkit/src/shared/crypto.ts new file mode 100644 index 0000000..d796646 --- /dev/null +++ b/packages/mcp-toolkit/src/shared/crypto.ts @@ -0,0 +1,53 @@ +/** Runtime-neutral crypto and encoding helpers on WebCrypto, so the package runs on Node and edge. */ + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export const utf8 = (text: string): Uint8Array => + encoder.encode(text) as Uint8Array; + +export const fromUtf8 = (bytes: Uint8Array): string => decoder.decode(bytes); + +export function toHex(bytes: Uint8Array): string { + let hex = ""; + for (const byte of bytes) hex += byte.toString(16).padStart(2, "0"); + return hex; +} + +export function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return globalThis.btoa(binary); +} + +/** Decodes standard base64. Throws on invalid input. */ +export function fromBase64(value: string): Uint8Array { + const binary = globalThis.atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +export const randomBytes = (length: number): Uint8Array => + crypto.getRandomValues(new Uint8Array(length)); + +export const randomHex = (length: number): string => toHex(randomBytes(length)); + +export const randomBase64Url = (length: number): string => + toBase64(randomBytes(length)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + +export async function sha256(input: string): Promise> { + return new Uint8Array(await crypto.subtle.digest("SHA-256", utf8(input))); +} + +export const sha256Hex = async (input: string): Promise => toHex(await sha256(input)); + +/** Compares two strings in time independent of where they differ. */ +export function constantTimeEqual(a: string, b: string): boolean { + const left = utf8(a); + const right = utf8(b); + if (left.length !== right.length) return false; + let diff = 0; + for (let i = 0; i < left.length; i++) diff |= (left[i] ?? 0) ^ (right[i] ?? 0); + return diff === 0; +} diff --git a/packages/mcp-toolkit/src/tasks/backends/memory.ts b/packages/mcp-toolkit/src/tasks/backends/memory.ts index 5ed7677..9c66f4b 100644 --- a/packages/mcp-toolkit/src/tasks/backends/memory.ts +++ b/packages/mcp-toolkit/src/tasks/backends/memory.ts @@ -1,14 +1,11 @@ /** - * 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. + * Single-process backends for tests and a first local run. Nothing here survives a restart, and + * {@link InlineTaskDispatcher} runs work in the process that took the tool call. */ import { isTerminal, UnknownTaskError, + type SettleResult, type Task, type TaskDispatcher, type TaskEndpoints, @@ -22,10 +19,12 @@ export class MemoryTaskStore implements TaskStore { private readonly tasks = new Map(); private readonly timers = new Map>(); - async create(task: Task): Promise { + async create(task: Task): Promise { + const existing = this.tasks.get(task.taskId); + if (existing) return { ...existing }; 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. + // Stands in for Redis' expiry. Unref'd so it never holds the process open. const timer = setTimeout(() => { this.tasks.delete(task.taskId); this.timers.delete(task.taskId); @@ -33,6 +32,7 @@ export class MemoryTaskStore implements TaskStore { timer.unref?.(); this.timers.set(task.taskId, timer); } + return null; } async get(taskId: string): Promise { @@ -41,77 +41,58 @@ export class MemoryTaskStore implements TaskStore { } async update(taskId: string, patch: TaskPatch): Promise { - const task = this.tasks.get(taskId); - if (!task) throw new UnknownTaskError(taskId); - // A terminal task is finished, message included — see the note on `TaskStore.update`. - if (isTerminal(task.status)) return { ...task }; - const next: Task = { ...task, ...patch, lastUpdatedAt: new Date().toISOString() }; - this.tasks.set(taskId, next); - return { ...next }; + const outcome = this.write(taskId, patch); + if (!outcome) throw new UnknownTaskError(taskId); + return outcome.task; } - 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 }; + async settle(taskId: string, patch: TerminalTaskPatch): Promise { + return this.write(taskId, patch); } - /** Drops every task and its pending expiry. Handy between tests. */ + /** Drops every task and its pending expiry. */ clear(): void { for (const timer of this.timers.values()) clearTimeout(timer); this.timers.clear(); this.tasks.clear(); } + + /** Synchronous, so nothing interleaves between the check and the write. */ + private write(taskId: string, patch: TaskPatch): SettleResult | null { + const task = this.tasks.get(taskId); + if (!task) return null; + if (isTerminal(task.status)) return { task: { ...task }, settled: false }; + const next: Task = { ...task, ...patch, lastUpdatedAt: new Date().toISOString() }; + this.tasks.set(taskId, next); + return { task: { ...next }, settled: true }; + } } /** - * 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()`. + * Runs a task in the current process, on the next microtask. Nothing is durable and `cancel` is a + * no-op, so stopping relies on the handler checking `isCancelled()`. A throw fails the task: there + * are no retries. */ 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; + async dispatch(task: Task): Promise { const endpoints = this.endpoints; if (!endpoints) { - throw new Error( - "This dispatcher is not attached to a task layer — pass it to createTaskLayer().", - ); + throw new Error("InlineTaskDispatcher is not attached — 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(() => endpoints.run(task.taskId, undefined)) .then( () => undefined, - // There are no retries in this process, so the first error is the last one. (cause: unknown) => endpoints - .fail(taskId, { + .fail(task.taskId, { code: -32603, message: cause instanceof Error ? cause.message : String(cause), }) @@ -125,11 +106,9 @@ export class InlineTaskDispatcher implements TaskDispatcher { return undefined; } - async cancel(): Promise { - // Nothing to un-enqueue: the work is already running in this process. - } + async cancel(): Promise {} - /** Resolves once every dispatched task has settled. Test-only. */ + /** Resolves once every dispatched task has settled. */ async drain(): Promise { while (this.pending.size > 0) await Promise.all([...this.pending]); } diff --git a/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts b/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts index 4548ef1..f1f4dde 100644 --- a/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts +++ b/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts @@ -1,6 +1,6 @@ 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 { dispatchKey, UnknownTaskError, type Task, type TaskError } from "../types.js"; import { cleanupKeys, hasRedisCreds, testRedis, uniquePrefix } from "../../test-support.js"; const makeTask = (overrides: Partial = {}): Task => { @@ -15,6 +15,7 @@ const makeTask = (overrides: Partial = {}): Task => { pollIntervalMs: 2_000, name: "generate_report", args: { topic: "coffee trends" }, + owner: "alice", ...overrides, }; }; @@ -100,12 +101,15 @@ describe.skipIf(!hasRedisCreds)("RedisTaskStore (real Redis)", () => { statusMessage: "Completed", result: { content: [{ type: "text", text: "done" }] }, }); - expect(completed?.status).toBe("completed"); - expect(completed?.result).toEqual({ content: [{ type: "text", text: "done" }] }); + expect(completed?.settled).toBe(true); + expect(completed?.task.status).toBe("completed"); + expect(completed?.task.result).toEqual({ content: [{ type: "text", text: "done" }] }); - // First terminal write wins: a later cancel cannot reopen or overwrite it. + // First terminal write wins: a later cancel cannot reopen or overwrite it, and gets the + // current record back in the same round trip. const cancelled = await store.settle(task.taskId, { status: "cancelled" }); - expect(cancelled).toBeNull(); + expect(cancelled?.settled).toBe(false); + expect(cancelled?.task.status).toBe("completed"); expect((await store.get(task.taskId))?.status).toBe("completed"); }); @@ -113,9 +117,13 @@ describe.skipIf(!hasRedisCreds)("RedisTaskStore (real Redis)", () => { const task = makeTask(); await store.create(task); - expect((await store.settle(task.taskId, { status: "cancelled" }))?.status).toBe("cancelled"); + expect((await store.settle(task.taskId, { status: "cancelled" }))?.task.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.settle(task.taskId, { status: "completed", result: {} }))?.settled).toBe( + false, + ); expect((await store.get(task.taskId))?.status).toBe("cancelled"); }); @@ -135,6 +143,33 @@ describe.skipIf(!hasRedisCreds)("RedisTaskStore (real Redis)", () => { expect(after.statusMessage).toBe("Cancelled by client"); }); + it("creates only if absent, and hands back the existing task otherwise", async () => { + const task = makeTask({ statusMessage: "first" }); + expect(await store.create(task)).toBeNull(); + + const again = await store.create({ ...task, statusMessage: "second" }); + expect(again?.taskId).toBe(task.taskId); + expect(again?.statusMessage).toBe("first"); + expect((await store.get(task.taskId))?.statusMessage).toBe("first"); + }); + + it("returns the record from update and settle in the same round trip", async () => { + const task = makeTask(); + await store.create(task); + const updated = await store.update(task.taskId, { statusMessage: "42" }); + expect(updated.statusMessage).toBe("42"); + expect(updated.args).toEqual(task.args); + + const settled = await store.settle(task.taskId, { + status: "completed", + result: { content: [{ type: "text", text: "ok" }] }, + }); + expect(settled?.task.status).toBe("completed"); + expect(settled?.task.result).toEqual({ content: [{ type: "text", text: "ok" }] }); + // A finished task's update returns it unchanged rather than failing. + expect((await store.update(task.taskId, { statusMessage: "late" })).statusMessage).toBe("42"); + }); + 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, @@ -169,7 +204,7 @@ describe("constructing without credentials", () => { 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"), + new QStashDispatcher({ url: "https://example.com/api/execute" }).dispatch(makeTask()), ).rejects.toThrow(/QSTASH_TOKEN/); }); }); @@ -216,6 +251,21 @@ describe("QStashDispatcher.createExecuteHandler", () => { /** 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("throws, rather than answering 401, when no signing keys are configured", async () => { + const saved = { ...process.env }; + delete process.env.QSTASH_CURRENT_SIGNING_KEY; + delete process.env.QSTASH_NEXT_SIGNING_KEY; + try { + const dispatcher = new QStashDispatcher({ url: "https://example.com/api/execute" }); + dispatcher.attach({ run: async () => undefined, fail: async () => undefined }); + await expect(dispatcher.createExecuteHandler()(deliver({ taskId: "t" }))).rejects.toThrow( + /signing keys/, + ); + } finally { + process.env = saved; + } + }); + it("runs a delivery and acknowledges with 200", async () => { const { handler, calls } = attached(); const response = await handler(deliver({ taskId: "t1" })); @@ -264,20 +314,20 @@ describe("QStashDispatcher.createExecuteHandler", () => { }); }); - it("rejects an unsigned delivery with 401 and never runs the task", async () => { + it("rejects an unsigned delivery as non-retryable 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); + // QStash retries every non-2xx except this one, and a retry cannot fix a bad signature. + expect(response.status).toBe(489); + expect(response.headers.get("Upstash-NonRetryable-Error")).toBe("true"); 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(deliver({}))).status).toBe(489); expect( ( await handler( @@ -288,7 +338,19 @@ describe("QStashDispatcher.createExecuteHandler", () => { }), ) ).status, - ).toBe(400); + ).toBe(489); + }); + + it("decodes a non-ASCII failure response as UTF-8", async () => { + const { handler, calls } = attached(); + await handler( + deliver({ + sourceBody: base64({ taskId: "t1" }), + status: 502, + body: Buffer.from("Ağ geçidi hatası ✗", "utf8").toString("base64"), + }), + ); + expect(calls.failed[0]?.error.data).toMatchObject({ response: "Ağ geçidi hatası ✗" }); }); it("verifies against the published URL, not the incoming one", async () => { @@ -321,3 +383,29 @@ describe("QStashDispatcher.createExecuteHandler", () => { ); }); }); + +describe("QStashDispatcher.dispatch", () => { + it("deduplicates per task record, not per task id", async () => { + const published: Record[] = []; + const qstash = { + publishJSON: async (options: Record) => { + published.push(options); + return { messageId: "msg_1" }; + }, + } as unknown as ConstructorParameters[0]["qstash"]; + const dispatcher = new QStashDispatcher({ url: "https://example.com/api/execute", qstash }); + + // A keyed task re-created after its record expired: same id, new createdAt. + const first = makeTask({ taskId: "t", createdAt: "2026-10-08T10:00:00.000Z" }); + const second = makeTask({ taskId: "t", createdAt: "2026-10-08T10:06:00.000Z" }); + expect(await dispatcher.dispatch(first)).toBe("msg_1"); + await dispatcher.dispatch(second); + + expect(published.map((p) => p.deduplicationId)).toEqual([ + dispatchKey(first), + dispatchKey(second), + ]); + expect(published[0]?.deduplicationId).not.toBe(published[1]?.deduplicationId); + expect(published[0]?.body).toEqual({ taskId: "t" }); + }); +}); diff --git a/packages/mcp-toolkit/src/tasks/backends/qstash.ts b/packages/mcp-toolkit/src/tasks/backends/qstash.ts index 33d2d9d..f4e2b3f 100644 --- a/packages/mcp-toolkit/src/tasks/backends/qstash.ts +++ b/packages/mcp-toolkit/src/tasks/backends/qstash.ts @@ -1,401 +1,277 @@ -/** - * 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; +/** The Upstash task backends: the record in Redis, the work delivered by QStash. */ +import type { Redis } from "@upstash/redis"; +import type { Client as QStashClient, Receiver } from "@upstash/qstash"; +import { + lazy, + nonRetryable, + resolveQStash, + resolveReceiver, + resolveRedis, +} from "../../shared/clients.js"; +import { fromBase64, fromUtf8 } from "../../shared/crypto.js"; import { - TERMINAL_STATUSES, + dispatchKey, UnknownTaskError, + type SettleResult, type Task, type TaskDispatcher, - type TaskPatch, type TaskEndpoints, type TaskError, + type TaskPatch, type TaskStore, type TerminalTaskPatch, } from "../types.js"; -import { addTelemetry } from "../../telemetry.js"; + +/** JSON-RPC internal error. */ +const INTERNAL_ERROR = -32603; /** 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. + * Backoff between deliveries: 1s, 3s, 9s, 27s, 81s. About two minutes over the default retries, + * long enough to outlast a deploy or a crash loop. */ 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. - */ +/** Delivery retries. 5 is the most QStash's free tier and local dev server accept. */ export const DEFAULT_RETRIES = 5; export type RedisTaskStoreConfig = { - /** The Upstash Redis client. Defaults to `Redis.fromEnv()`. */ + /** + * The Upstash Redis client. Defaults to one from `UPSTASH_REDIS_REST_URL` / `_TOKEN`. A client + * built with `automaticDeserialization: false` is not supported. + */ redis?: Redis; - /** Key prefix for task hashes. Defaults to {@link DEFAULT_TASK_PREFIX}. */ + /** Key prefix. Defaults to {@link DEFAULT_TASK_PREFIX}. */ prefix?: string; - /** Set `false` to skip reporting the SDK version in the Redis telemetry header. */ + /** Set `false` to skip this package's tag in the 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"]); +/** Creates the hash and its TTL unless the key exists, in which case it returns the existing fields. */ +const CREATE_SCRIPT = `#!lua flags=allow-key-locking +if redis.call('EXISTS', KEYS[1]) == 1 then return redis.call('HGETALL', KEYS[1]) end +redis.call('HSET', KEYS[1], unpack(ARGV, 2)) +local ttl = tonumber(ARGV[1]) +if ttl > 0 then redis.call('PEXPIRE', KEYS[1], ttl) end +return false +`; /** - * 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. + * Applies fields only while the task is non-terminal. Returns `[changed, ...fields]`, or nil when + * the task is missing. Values are JSON-encoded, hence the quoted statuses. */ -const UPDATE_SCRIPT = `#!lua flags=allow-key-locking -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 +const GUARDED_WRITE_SCRIPT = `#!lua flags=allow-key-locking +local status = redis.call('HGET', KEYS[1], 'status') +if not status then return false end +local changed = 0 +if status ~= '"completed"' and status ~= '"failed"' and status ~= '"cancelled"' then + redis.call('HSET', KEYS[1], unpack(ARGV)) + changed = 1 end -for i = 2 + terminals, #ARGV, 2 do - redis.call('HSET', KEYS[1], ARGV[i], ARGV[i + 1]) -end -return 1 +local fields = redis.call('HGETALL', KEYS[1]) +table.insert(fields, 1, changed) +return fields `; -const SETTLE_SCRIPT = `#!lua flags=allow-key-locking -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. + * One Redis hash per task, so concurrent writers only touch their own fields. The TTL is set at + * creation and never extended. */ export class RedisTaskStore implements TaskStore { private readonly prefix: string; - private readonly enableTelemetry: boolean; - private readonly resolveRedis: () => Redis; - private client: Redis | undefined; + private readonly redis: () => Redis; - 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; + constructor(config: RedisTaskStoreConfig = {}) { + this.prefix = config.prefix ?? DEFAULT_TASK_PREFIX; + this.redis = lazy(() => resolveRedis("RedisTaskStore", config.redis, config.enableTelemetry)); } - 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 create(task: Task): Promise { + const ttl = task.ttlMs !== null && task.ttlMs > 0 ? task.ttlMs : 0; + const existing = await this.redis().eval( + CREATE_SCRIPT, + [this.key(task.taskId)], + [String(ttl), ...toArgs(task)], + ); + return toTask(existing); } 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); + const fields = await this.redis().hgetall>(this.key(taskId)); + return fields && Object.keys(fields).length > 0 ? fromFields(fields) : null; } 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; + const outcome = await this.guardedWrite(taskId, patch); + if (!outcome) throw new UnknownTaskError(taskId); + return outcome.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; + async settle(taskId: string, patch: TerminalTaskPatch): Promise { + return await this.guardedWrite(taskId, patch); } /** The Redis key a task is stored under. */ key(taskId: string): string { return this.prefix + taskId; } + + private async guardedWrite(taskId: string, patch: TaskPatch): Promise { + const reply = await this.redis().eval( + GUARDED_WRITE_SCRIPT, + [this.key(taskId)], + toArgs({ ...patch, lastUpdatedAt: new Date().toISOString() }), + ); + if (!Array.isArray(reply)) return null; + const [changed, ...fields] = reply; + const task = toTask(fields); + return task ? { task, settled: Number(changed) === 1 } : null; + } } 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)`. - */ + /** The public URL QStash delivers to: the route serving `tasks.createExecuteHandler()`. */ url: string; - /** - * Delivery attempts before QStash gives up and dead-letters the message. Defaults to - * {@link DEFAULT_RETRIES}. - */ + /** The QStash client. Defaults to one from `QSTASH_TOKEN` (and `QSTASH_URL`). */ + qstash?: QStashClient; + /** Delivery retries before QStash dead-letters a task. 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. + * Backoff as a QStash delay expression. Defaults to {@link DEFAULT_RETRY_DELAY}. Size the total + * budget against your deploys: it has to outlast a restart. */ retryDelay?: string; - /** Extra headers to send with the delivery. */ + /** Extra headers to send with each 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`. + * Verifies deliveries. Defaults to one from `QSTASH_CURRENT_SIGNING_KEY` / + * `QSTASH_NEXT_SIGNING_KEY`. Required either way: without keys the endpoint refuses to serve. */ receiver?: Receiver; + /** Set `false` to skip this package's tag in the telemetry header. */ + enableTelemetry?: boolean; }; /** - * 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. + * Publishes each task to QStash, which stores it durably and retries a failing endpoint. The whole + * handler runs in one invocation, so it is bound by your function's time limit; use + * `WorkflowDispatcher` for longer 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 readonly config: QStashDispatcherConfig; + private readonly qstash: () => QStashClient; + private readonly receiver: () => Receiver; 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(); + this.config = config; + this.qstash = lazy(() => + resolveQStash("QStashDispatcher", config.qstash, config.enableTelemetry), + ); + this.receiver = lazy(() => resolveReceiver("QStashDispatcher", config.receiver)); } 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, + async dispatch(task: Task): Promise { + const message = await this.qstash().publishJSON({ + url: this.config.url, + body: { taskId: task.taskId }, + retries: this.config.retries ?? DEFAULT_RETRIES, + retryDelay: this.config.retryDelay ?? DEFAULT_RETRY_DELAY, + headers: this.config.headers, + // Comes back to the same route once retries are exhausted, and settles the task `failed`. + failureCallback: this.config.url, + // Per record, not per id: QStash remembers ids for 10 minutes, longer than a keyed task's TTL. + deduplicationId: dispatchKey(task), }); return Array.isArray(message) ? message[0]?.messageId : message.messageId; } async cancel(dispatchId: string): Promise { - await this.qstash.messages.cancel(dispatchId); + 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. + * The delivery endpoint. A delivery (`{ taskId }`) runs the task; a failure callback + * (`sourceBody`) settles it `failed`. * - * 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. + * - **200**: done. + * - **500**: the handler threw; QStash delivers again. + * - **489** with `Upstash-NonRetryable-Error`: bad signature or body. QStash retries every other + * non-2xx, so this is the only way to stop it. */ 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().", - ); + throw new Error("QStashDispatcher is not attached — pass it to createTaskLayer()."); } - + // Outside the try: missing signing keys are a configuration error, not a bad signature. + const receiver = this.receiver(); 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({ + // Against the URL we published to: behind a proxy, `request.url` is the internal one. + await receiver.verify({ signature: request.headers.get("upstash-signature") ?? "", body, - url: this.url, + url: this.config.url, }); } catch { - return new Response("invalid signature", { status: 401 }); + return nonRetryable("invalid signature"); } - let payload: QStashDelivery; + let payload: DeliveryBody; try { - payload = JSON.parse(body) as QStashDelivery; + payload = JSON.parse(body) as DeliveryBody; } catch { - return new Response("malformed body", { status: 400 }); + return nonRetryable("malformed body"); } 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 }); + if (!payload.taskId) return nonRetryable("missing taskId"); 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. + } catch (error) { + console.error( + `[mcp-toolkit] task ${payload.taskId} failed, asking QStash to retry:`, + error, + ); 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. */ +/** Either body QStash posts to the execute endpoint. */ +type DeliveryBody = { + /** A delivery: the body we published. */ taskId?: string; - /** Present on a failure callback: base64 of the body of the message that failed. */ + /** A failure callback: base64 of the failed message's body. */ sourceBody?: string; - /** The failed response's status. */ + /** The last response's status. */ status?: number; - /** Base64 of the failed response's body. */ + /** Base64 of the last 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, + payload: DeliveryBody, ): { 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; @@ -403,8 +279,7 @@ function readFailureCallback( return undefined; } if (!taskId) return undefined; - - const responseBody = typeof payload.body === "string" ? decodeBase64(payload.body) : undefined; + const response = typeof payload.body === "string" ? decodeBase64(payload.body) : undefined; return { taskId, error: { @@ -412,93 +287,34 @@ function readFailureCallback( 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 }, + data: { dlqId: payload.dlqId, status: payload.status, response }, }, }; } -/** - * 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; -} +const decodeBase64 = (value: string): string => fromUtf8(fromBase64(value)); /** - * 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. + * Hash fields as `HSET` arguments. Every value is JSON-encoded, so the client's automatic + * deserialization on read is its exact inverse (`"123"` stays a string). */ -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.", - ); +function toArgs(patch: Partial): string[] { + const args: string[] = []; + for (const [field, value] of Object.entries(patch)) { + if (value !== undefined) args.push(field, JSON.stringify(value ?? null)); } - return new Redis({ url, token }); + return args; } -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 }); +/** A flat field/value list from a script's `HGETALL`, as a task. */ +function toTask(reply: unknown[] | null | undefined): Task | null { + if (!Array.isArray(reply) || reply.length === 0) return null; + const fields: Record = {}; + for (let i = 0; i + 1 < reply.length; i += 2) fields[String(reply[i])] = reply[i + 1]; + return fromFields(fields); } -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 }); +function fromFields(fields: Record): Task { + // `ttlMs: null` means unlimited, so an absent field reads as null too. + return { ttlMs: null, ...fields } as Task; } diff --git a/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts b/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts index 1b0e5f4..2927143 100644 --- a/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts +++ b/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts @@ -5,11 +5,26 @@ * 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 { Client as QStashClient } from "@upstash/qstash"; +import { WorkflowContext } from "@upstash/workflow"; import { describe, expect, it } from "vitest"; -import { WorkflowDispatcher } from "./workflow.js"; +import { insideStep, WorkflowDispatcher } from "./workflow.js"; import { createTaskLayer } from "../core.js"; import { MemoryTaskStore } from "./memory.js"; -import type { TaskContext } from "../types.js"; +import { dispatchKey, type Task, type TaskContext } from "../types.js"; +import { ManualDispatcher } from "../../test-support.js"; + +const task = (overrides: Partial = {}): Task => ({ + taskId: "task-1", + status: "working", + createdAt: "2026-10-08T10:00:00.000Z", + lastUpdatedAt: "2026-10-08T10:00:00.000Z", + ttlMs: null, + name: "demo", + args: {}, + owner: "local", + ...overrides, +}); type Triggered = { url: string; body: unknown; workflowRunId?: string }; @@ -34,18 +49,20 @@ function stubClient() { } describe("WorkflowDispatcher", () => { - it("triggers a run named after the task, so a double dispatch is deduplicated", async () => { + it("triggers a run named after the task record, 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"); + const dispatchId = await dispatcher.dispatch(task()); 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"); + // Per record: Workflow refuses a used run id, and a keyed task id comes back after expiry. + expect(triggered[0]?.workflowRunId).toBe(dispatchKey(task())); + expect(dispatchId).toBe(dispatchKey(task())); + await dispatcher.dispatch(task({ createdAt: "2026-10-08T10:06:00.000Z" })); + expect(triggered[1]?.workflowRunId).not.toBe(triggered[0]?.workflowRunId); }); it("cancels the run itself, not just the task record", async () => { @@ -86,10 +103,8 @@ describe("the context a handler receives", () => { handler: (task: TaskContext & TContext) => Promise>, ) { const store = new MemoryTaskStore(); - const tasks = createTaskLayer({ - store, - dispatcher: { dispatch: async () => undefined, cancel: async () => undefined }, - }); + const dispatcher = new ManualDispatcher(); + const tasks = createTaskLayer({ store, dispatcher, principal: () => "local" }); const now = new Date().toISOString(); await store.create({ @@ -100,21 +115,17 @@ describe("the context a handler receives", () => { ttlMs: null, name: "demo", args: {}, + owner: "local", }); - const server = { - registerTool: () => undefined, - server: { registerCapabilities: () => undefined, setRequestHandler: () => undefined }, - } as unknown as Parameters[0]; - - tasks.registerTask( - server, + tasks.define( "demo", { description: "d", inputSchema: { "~standard": {} } as never }, async (_args, task) => await handler(task), ); - return await tasks.executeTask("t1", context); + await dispatcher.run("t1", context); + return await store.get("t1"); } it("is just the task context when the transport adds nothing", async () => { @@ -147,10 +158,8 @@ describe("the context a handler receives", () => { 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 dispatcher = new ManualDispatcher(); + const tasks = createTaskLayer({ store, dispatcher, principal: () => "local" }); const now = new Date().toISOString(); await store.create({ taskId: "t1", @@ -160,13 +169,9 @@ describe("the context a handler receives", () => { ttlMs: null, name: "demo", args: {}, + owner: "local", }); - const server = { - registerTool: () => undefined, - server: { registerCapabilities: () => undefined, setRequestHandler: () => undefined }, - } as unknown as Parameters[0]; - tasks.registerTask( - server, + tasks.define( "demo", { description: "d", inputSchema: { "~standard": {} } as never }, async (_args, task) => { @@ -176,7 +181,7 @@ describe("the context a handler receives", () => { }, ); - await tasks.executeTask("t1", undefined, async (name, fn) => { + await dispatcher.run("t1", undefined, async (name, fn) => { journaled.push(name); return await fn(); }); @@ -207,3 +212,42 @@ describe("the context a handler receives", () => { }); }); }); + +describe("WorkflowDispatcher signature verification", () => { + it("refuses to serve without signing keys, instead of running unverified", async () => { + // The Workflow SDK's own default is to skip verification when the keys are missing. + const saved = { ...process.env }; + delete process.env.QSTASH_CURRENT_SIGNING_KEY; + delete process.env.QSTASH_NEXT_SIGNING_KEY; + process.env.QSTASH_TOKEN = "test-token"; + try { + const dispatcher = new WorkflowDispatcher({ url: "https://example.com/api/workflow" }); + const handler = dispatcher.createExecuteHandler(); // building the route must not throw + expect(() => + handler(new Request("https://example.com/api/workflow", { method: "POST", body: "{}" })), + ).toThrow(/signing keys/); + } finally { + process.env = saved; + } + }); +}); + +describe("insideStep", () => { + // It reads Workflow's non-public `executor.executingStep`. If an upgrade renames the field this + // fails, instead of task.update silently going unjournaled. + it("still finds executingStep on a real WorkflowContext", async () => { + const context = new WorkflowContext({ + qstashClient: new QStashClient({ token: "test" }) as never, + workflowRunId: "wfr_test", + workflowRunCreatedAt: Date.now(), + headers: new Headers(), + steps: [], + url: "https://example.com/api/workflow", + initialPayload: {}, + }); + const executor = (context as unknown as { executor?: Record }).executor; + expect(executor).toBeDefined(); + expect(executor).toHaveProperty("executingStep", false); + expect(insideStep(context as never)).toBe(false); + }); +}); diff --git a/packages/mcp-toolkit/src/tasks/backends/workflow.ts b/packages/mcp-toolkit/src/tasks/backends/workflow.ts index 1935721..2626a7e 100644 --- a/packages/mcp-toolkit/src/tasks/backends/workflow.ts +++ b/packages/mcp-toolkit/src/tasks/backends/workflow.ts @@ -1,167 +1,149 @@ /** - * 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. + * The Upstash Workflow dispatcher, for work longer than one function invocation: every + * `task.run(...)` step is its own request, and a finished step is replayed from the journal. */ -import { Client as WorkflowClient } from "@upstash/workflow"; -import { serve, type WorkflowContext } from "@upstash/workflow"; -import type { TaskDispatcher, TaskEndpoints, TaskJournal } from "../types.js"; +import type { Client as QStashClient, Receiver } from "@upstash/qstash"; +import { Client as WorkflowClient, serve, type WorkflowContext } from "@upstash/workflow"; +import { env, lazy, requireEnv, resolveQStash, resolveReceiver } from "../../shared/clients.js"; +import { addQStashTelemetry } from "../../telemetry.js"; +import { + dispatchKey, + type Task, + type TaskDispatcher, + type TaskEndpoints, + type TaskJournal, +} from "../types.js"; -/** JSON-RPC internal error, per the MCP spec — inlined so this file imports no MCP SDK. */ +/** JSON-RPC internal error. */ 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. - */ + /** The public URL of the route serving `tasks.createExecuteHandler()`, reachable from QStash. */ url: string; + /** The Workflow client. Defaults to one from `QSTASH_TOKEN` (and `QSTASH_URL`). */ + client?: WorkflowClient; /** Extra headers to send when triggering a run. */ headers?: Record; + /** Retries per step. Defaults to the Workflow SDK's default. */ + retries?: number; /** - * 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. + * Verifies every request to the endpoint. Defaults to one from the `QSTASH_*_SIGNING_KEY` env + * vars. Required either way: Workflow itself skips verification when they are missing. */ - retries?: number; + receiver?: Receiver; + /** The QStash client the endpoint schedules steps with. Defaults to one from `QSTASH_TOKEN`. */ + qstash?: QStashClient; + /** Set `false` to skip this package's tag in the telemetry header. */ + enableTelemetry?: boolean; }; -/** 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: `task_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. + * Runs each task as a Workflow run. Handlers get the live `WorkflowContext`, so `task.run`, + * `task.sleep` and `task.call` are the real thing. Cancelling a task cancels the run. */ 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 readonly config: WorkflowDispatcherConfig; + private readonly client: () => WorkflowClient; + private readonly handler: () => (request: Request) => Promise; private endpoints: TaskEndpoints> | undefined; constructor(config: WorkflowDispatcherConfig) { - this.url = config.url; - this.headers = config.headers; - this.retries = config.retries; - this.resolveClient = () => config.client ?? clientFromEnv(); + this.config = config; + this.client = lazy(() => { + const client = + config.client ?? + new WorkflowClient({ + token: requireEnv("WorkflowDispatcher", "QSTASH_TOKEN"), + baseUrl: env("QSTASH_URL"), + }); + addQStashTelemetry(client, { enabled: config.enableTelemetry ?? true }); + return client; + }); + // Built on the first request: route modules are evaluated at build time, without the keys. + this.handler = lazy(() => this.buildHandler()); } 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, + async dispatch(task: Task): Promise { + const { workflowRunId } = await this.client().trigger({ + url: this.config.url, + body: { taskId: task.taskId } satisfies WorkflowPayload, + headers: this.config.headers, + retries: this.config.retries, + // Unique per record: Workflow refuses a run id that was already used. + workflowRunId: dispatchKey(task), }); return workflowRunId; } async cancel(dispatchId: string): Promise { - await this.client.cancel(dispatchId); + 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`. - */ + /** The workflow endpoint. Its `failureFunction` settles the task `failed` once retries run out. */ createExecuteHandler(): (request: Request) => Promise { + return (request) => this.handler()(request); + } + + private buildHandler(): (request: Request) => Promise { + // Resolved here, so a missing key throws instead of serving unverified. + const receiver = resolveReceiver("WorkflowDispatcher", this.config.receiver); + const qstashClient = resolveQStash( + "WorkflowDispatcher", + this.config.qstash, + this.config.enableTelemetry, + ); 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)); + if (taskId) await this.required().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, { + await this.required().fail(taskId, { code: INTERNAL_ERROR, message: `Workflow run failed${failStatus ? ` (status ${failStatus})` : ""}`, data: { response: failResponse, workflowRunId: context.workflowRunId }, }); }, + receiver, + qstashClient, + // The public URL, as with QStash: behind a proxy `request.url` is the internal one. + url: this.config.url, }, ); - return handler; } private required(): TaskEndpoints> { if (!this.endpoints) { - throw new Error( - "This dispatcher is not attached to a task layer — pass it to createTaskLayer().", - ); + throw new Error("WorkflowDispatcher is not attached — 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. + * Journals the core's own writes at the top level only: Workflow rejects a step inside a step, and + * a handler may call `task.update` from inside its own `task.run`. */ function journalFor(context: WorkflowContext): TaskJournal { return async (name, fn) => (insideStep(context) ? await fn() : await context.run(name, fn)); } -function insideStep(context: WorkflowContext): boolean { +/** + * Reads Workflow's non-public `executor.executingStep`. If it disappears this falls back to a + * plain write, and a test fails so we notice. + */ +export 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-toolkit/src/tasks/core.test.ts b/packages/mcp-toolkit/src/tasks/core.test.ts index bd518d4..5c0abe2 100644 --- a/packages/mcp-toolkit/src/tasks/core.test.ts +++ b/packages/mcp-toolkit/src/tasks/core.test.ts @@ -8,7 +8,8 @@ import * as z from "zod"; import { createTaskLayer } from "./core.js"; import { InlineTaskDispatcher, MemoryTaskStore } from "./backends/memory.js"; import type { TaskContext, TaskLayer, TaskToolConfig, WireTask } from "./index.js"; -import { sleep } from "../test-support.js"; +import { CountingDispatcher, ManualDispatcher, sleep } from "../test-support.js"; +import { dispatchKey, type Task } from "./types.js"; const PROTOCOL_VERSION = "2026-07-28"; @@ -18,23 +19,30 @@ type ToolResult = { isError?: boolean; }; -type Call = (name: string, args: Record, user?: string) => Promise; +/** `user` defaults to "alice"; `null` sends the request with no auth at all. */ +type User = string | null | undefined; + +type Call = (name: string, args: Record, user?: User) => Promise; type Harness = { call: Call; /** Starts the report tool and returns the task id it answered with. */ - start: (topic?: string, user?: string) => Promise; - status: (taskId: string, user?: string) => Promise; - cancel: (taskId: string, user?: string) => Promise; + start: (topic?: string, user?: User) => Promise; + status: (taskId: string, user?: User) => Promise; + cancel: (taskId: string, user?: User) => Promise; listTools: () => Promise; tasks: TaskLayer; store: MemoryTaskStore; - dispatcher: InlineTaskDispatcher; + dispatcher: CountingDispatcher; close: () => Promise; }; type ReportArgs = { topic: string }; +/** Every test identifies callers by the user id their auth carries. */ +const principal = (auth: { extra?: Record } | undefined) => + auth?.extra?.userId as string | undefined; + /** Builds a server with one task tool backed by `handler`. */ async function harness( handler: (args: ReportArgs, task: TaskContext) => Promise>, @@ -43,28 +51,31 @@ async function harness( ): Promise { const store = new MemoryTaskStore(); const dispatcher = - (layer.dispatcher as InlineTaskDispatcher | undefined) ?? new InlineTaskDispatcher(); - const tasks = createTaskLayer({ store, ...layer, dispatcher }); + (layer.dispatcher as CountingDispatcher | undefined) ?? new CountingDispatcher(); + const tasks = createTaskLayer({ store, principal, ...layer, dispatcher }); + + // Defined once, the way module scope does it in an app. + tasks.define( + "generate_report", + { + description: "Generates a report.", + inputSchema: z.object({ topic: z.string() }), + ...tool, + }, + handler, + ); // A fresh server per request, the way `createMcpHandler` serves stateless traffic — the same // path the demo uses. Registering on each one is cheap; the task layer's state is the store. const handlerFor = createMcpHandler(() => { const server = new McpServer({ name: "test", version: "1.0.0" }); - tasks.registerTask( - server, - "generate_report", - { - description: "Generates a report.", - inputSchema: z.object({ topic: z.string() }), - ...tool, - }, - handler, - ); + tasks.register(server); return server; }); let id = 0; - const rpc = async (method: string, params: Record, user?: string) => { + const rpc = async (method: string, params: Record, given?: User) => { + const user = given === undefined ? "alice" : given; const headers: Record = { "content-type": "application/json", accept: "application/json, text/event-stream", @@ -149,6 +160,45 @@ describe("createTaskLayer over MCP", () => { ); }); + it("runs a task on an instance that never built a server", async () => { + // The execute endpoint on a cold instance: the layer was created and the task defined at + // module scope, but no MCP request — and so no `register` — ever happened there. + const store = new MemoryTaskStore(); + const dispatcher = new ManualDispatcher(); + const coldInstance = createTaskLayer({ store, dispatcher, principal }); + coldInstance.define( + "generate_report", + { description: "Generates a report.", inputSchema: z.object({ topic: z.string() }) }, + async ({ topic }) => ({ content: [{ type: "text", text: topic }] }), + ); + const now = new Date().toISOString(); + await store.create({ + taskId: "t1", + status: "working", + createdAt: now, + lastUpdatedAt: now, + ttlMs: null, + name: "generate_report", + args: { topic: "ferries" }, + owner: "alice", + }); + await dispatcher.run("t1"); + expect((await store.get("t1"))?.status).toBe("completed"); + store.clear(); + }); + + it("refuses to define the same task twice, or under a shared tool's name", () => { + const tasks = createTaskLayer({ + store: new MemoryTaskStore(), + dispatcher: new InlineTaskDispatcher(), + principal, + }); + const config = { description: "d", inputSchema: z.object({}) }; + tasks.define("a", config, async () => ({})); + expect(() => tasks.define("a", config, async () => ({}))).toThrow(/already defined/); + expect(() => tasks.define("task_status", config, async () => ({}))).toThrow(/reserved/); + }); + it("answers the tool call with a task handle, as an ordinary tool result", async () => { live = await harness(steppedHandler()); const result = await live.call("generate_report", { topic: "coffee trends" }); @@ -255,11 +305,8 @@ describe("createTaskLayer over MCP", () => { }); describe("ownership", () => { - const principal = (auth: { extra?: Record } | undefined) => - auth?.extra?.userId as string | undefined; - it("lets the owner read and cancel its task", async () => { - live = await harness(steppedHandler(4, 30), { principal }); + live = await harness(steppedHandler(4, 30)); const taskId = await live.start("x", "alice"); expect((await live.store.get(taskId))?.owner).toBe("alice"); expect((await live.status(taskId, "alice")).structuredContent?.status).toBe("working"); @@ -268,7 +315,7 @@ describe("createTaskLayer over MCP", () => { }); it("reports another caller's task as unknown, and refuses to cancel it", async () => { - live = await harness(steppedHandler(4, 30), { principal }); + live = await harness(steppedHandler(4, 30)); const taskId = await live.start("x", "alice"); const peek = await live.status(taskId, "mallory"); @@ -280,17 +327,42 @@ describe("createTaskLayer over MCP", () => { expect((await live.store.get(taskId))?.status).not.toBe("cancelled"); // Nor does an anonymous caller get through. - expect((await live.status(taskId)).isError).toBe(true); + expect((await live.status(taskId, null)).isError).toBe(true); await live.dispatcher.drain(); }); - it("leaves tasks unscoped when no principal is configured", async () => { + it("refuses every call from a caller the principal cannot identify", async () => { live = await harness(steppedHandler(1, 1)); + const start = await live.call("generate_report", { topic: "x" }, null); + expect(start.isError).toBe(true); + expect(text(start)).toMatch(/Not authenticated/); + expect(live.dispatcher.dispatched).toBe(0); + const taskId = await live.start("x", "alice"); - expect((await live.store.get(taskId))?.owner).toBeUndefined(); - expect((await live.status(taskId, "bob")).isError).toBeFalsy(); + expect(text(await live.status(taskId, null))).toMatch(/Not authenticated/); + expect(text(await live.cancel(taskId, null))).toMatch(/Not authenticated/); await live.dispatcher.drain(); }); + + it("never answers for a stored task without an owner", async () => { + live = await harness(steppedHandler(1, 1)); + const taskId = await live.start("x", "alice"); + await live.dispatcher.drain(); + // A record written by something other than the layer, with no owner on it. + const record = await live.store.get(taskId); + live.store.clear(); + await live.store.create({ ...record!, owner: undefined as unknown as string }); + expect(text(await live.status(taskId, "alice"))).toMatch(/Unknown task/); + }); + + it("requires a principal", () => { + expect(() => + createTaskLayer({ + store: new MemoryTaskStore(), + dispatcher: new InlineTaskDispatcher(), + } as unknown as Parameters[0]), + ).toThrow(/principal/); + }); }); describe("idempotency", () => { @@ -309,11 +381,7 @@ describe("createTaskLayer over MCP", () => { }); it("scopes keys by owner, so two callers never share a task", async () => { - live = await harness( - steppedHandler(1, 1), - { principal: (auth) => auth?.extra?.userId as string | undefined }, - { idempotencyKey: (args) => args.topic }, - ); + live = await harness(steppedHandler(1, 1), {}, { idempotencyKey: (args) => args.topic }); const alice = await live.start("x", "alice"); const bob = await live.start("x", "bob"); expect(alice).not.toBe(bob); @@ -330,44 +398,46 @@ describe("createTaskLayer over MCP", () => { 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 manual = new ManualDispatcher(); + live = await harness( + async () => { + runs += 1; + return { content: [{ type: "text", text: "done" }] }; + }, + { dispatcher: manual as never }, + ); const taskId = await live.start("x"); - await live.dispatcher.drain(); - expect(runs).toBe(1); - + await manual.run(taskId); // The same message arriving twice is the contract, not a bug. - await live.tasks.executeTask(taskId); - await live.tasks.executeTask(taskId); + await manual.run(taskId); + await manual.run(taskId); expect(runs).toBe(1); }); it("acknowledges a delivery for a task that no longer exists", async () => { - live = await harness(steppedHandler(1, 1)); - // Expired or never created: a retry cannot fix that, so it must not throw and be retried. - await expect(live.tasks.executeTask("gone")).resolves.toBeNull(); + const manual = new ManualDispatcher(); + live = await harness(steppedHandler(1, 1), { dispatcher: manual as never }); + // Expired or never created: a retry cannot fix that, so it must not throw. + await expect(manual.run("gone")).resolves.toBeUndefined(); }); it("leaves a thrown task retryable rather than settling it failed", async () => { let attempts = 0; + const manual = new ManualDispatcher(); 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 }) }, + { dispatcher: manual as never }, ); const taskId = await live.start("x"); - - // Redeliveries are driven by hand, 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"); + // Only the transport decides that a failure is final. + await expect(manual.run(taskId)).rejects.toThrow("boom 1"); expect((await live.status(taskId)).structuredContent?.status).toBe("working"); - await expect(live.tasks.executeTask(taskId)).rejects.toThrow("boom 2"); - await live.tasks.executeTask(taskId); + await expect(manual.run(taskId)).rejects.toThrow("boom 2"); + await manual.run(taskId); expect((await live.status(taskId)).structuredContent?.status).toBe("completed"); expect(attempts).toBe(3); }); @@ -384,6 +454,61 @@ describe("createTaskLayer over MCP", () => { expect(after.structuredContent?.error).toMatchObject({ code: -32603, message: "permanent" }); expect(text(after)).toMatch(/permanent/); }); + + it("fails the task when it cannot be dispatched, instead of leaving it working", async () => { + const manual = new ManualDispatcher(); + manual.failNext = new Error("queue unavailable"); + const settled: Task[] = []; + live = await harness(steppedHandler(1, 1), { + dispatcher: manual as never, + onSettle: (task) => { + settled.push(task); + }, + }); + const result = await live.call("generate_report", { topic: "x" }); + expect(result.isError).toBe(true); + expect(settled).toHaveLength(1); + expect(settled[0]).toMatchObject({ status: "failed", statusMessage: "Could not be queued" }); + }); + + it("dispatches a re-created keyed task under a new dispatch key", async () => { + const manual = new ManualDispatcher(); + live = await harness( + steppedHandler(1, 1), + { dispatcher: manual as never }, + { idempotencyKey: (args) => args.topic, ttlMs: 30 }, + ); + const first = await live.start("x"); + await sleep(60); // the record expires; the same key now makes a new record with the same id + const second = await live.start("x"); + expect(second).toBe(first); + expect(manual.dispatched).toHaveLength(2); + const [a, b] = manual.dispatched.map(dispatchKey); + expect(a).not.toBe(b); + }); + }); + + it("calls onSettle once and cancels the dispatch once, however often a task is cancelled", async () => { + const manual = new ManualDispatcher(); + const settled: Task[] = []; + live = await harness(steppedHandler(1, 1), { + dispatcher: manual as never, + onSettle: (task) => { + settled.push(task); + }, + }); + const taskId = await live.start("x"); + await live.cancel(taskId); + await live.cancel(taskId); + expect(settled.map((t) => t.status)).toEqual(["cancelled"]); + expect(manual.cancelled).toEqual(["msg_1"]); + }); + + it("keeps ttlMs: null (unlimited) instead of falling back to the default", async () => { + live = await harness(steppedHandler(1, 1), {}, { ttlMs: null }); + const result = await live.call("generate_report", { topic: "x" }); + expect(result.structuredContent?.ttlMs).toBeNull(); + await live.dispatcher.drain(); }); it("registers the shared tools under custom names when asked", async () => { diff --git a/packages/mcp-toolkit/src/tasks/core.ts b/packages/mcp-toolkit/src/tasks/core.ts index fe5e2f2..a3ffdde 100644 --- a/packages/mcp-toolkit/src/tasks/core.ts +++ b/packages/mcp-toolkit/src/tasks/core.ts @@ -1,23 +1,17 @@ /** - * The tasks runtime. - * - * Long-running work over MCP, served as **ordinary tools**. A task tool answers immediately with a - * task handle; two shared tools, `task_status` and `task_cancel`, poll and stop it. The work runs - * behind a {@link TaskDispatcher} (QStash, Upstash Workflow, or in-process), and the record lives - * in a {@link TaskStore} (Upstash Redis, or memory). - * - * Why tools rather than the protocol's own Tasks extension (`io.modelcontextprotocol/tasks`): as of - * October 2026 no mainstream client declares it — not Claude Code, Codex, Cursor or OpenCode — and - * a server must never return a task to a client that did not. Plain tools work in every client - * today, and they need nothing from the SDK beyond `registerTool`, so this layer serves through - * `createMcpHandler`, `mcp-handler` or any transport unchanged. The store and dispatcher are the - * same either way, so a native adapter can sit on top of them once clients catch up. - * - * {@link createTaskLayer} is the whole runtime, in one factory over a store and a dispatcher. + * The tasks runtime: long-running work served as ordinary MCP tools. A task tool answers at once + * with a task id; the shared `task_status` and `task_cancel` tools poll and stop it. The README + * explains why tools rather than the Tasks extension. */ -import { createHash, randomUUID } from "node:crypto"; import type { McpServer, StandardSchemaWithJSON } from "@modelcontextprotocol/server"; import * as z from "zod"; +import { + authOf, + requirePrincipal, + type CallerAuth, + type PrincipalResolver, +} from "../shared/auth.js"; +import { sha256Hex } from "../shared/crypto.js"; import { isTerminal, UnknownTaskError, @@ -32,94 +26,59 @@ import { const DEFAULT_TTL_MS = 300_000; const DEFAULT_POLL_INTERVAL_MS = 2_000; +/** JSON-RPC internal error. */ +const INTERNAL_ERROR = -32603; /** The names the two shared tools are registered under, unless overridden. */ export const DEFAULT_TOOL_NAMES = { status: "task_status", cancel: "task_cancel" } as const; -/** - * The slice of the SDK's `AuthInfo` this layer reads. Typed structurally so a principal resolver - * does not need to import the SDK's auth types. - */ -export type CallerAuth = { - clientId?: string; - scopes?: string[]; - extra?: Record; - [key: string]: unknown; -}; +export type { CallerAuth }; 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. */ + /** Retention window from creation. `null` means unlimited. Defaults to 5 minutes. */ ttlMs?: number | null; - /** Suggested poll interval, returned to the model. Defaults to 2s. */ + /** Poll interval suggested to the model. Defaults to 2s. */ pollIntervalMs?: number; }; /** - * Who is calling, as a stable string — usually your user id. When it returns a value, every - * task records it as its owner, and `task_status` / `task_cancel` only answer for tasks the same - * caller owns. Another caller's task id reads exactly like an unknown one. - * - * Receives the `AuthInfo` your auth middleware attached to the request. Leave it unset only for - * a single-tenant server: without it, anyone holding a task id can read and cancel that task. - * - * ```ts - * principal: (auth) => auth?.extra?.userId as string | undefined - * ``` - * - * Note that `auth.clientId` is the OAuth *client* (e.g. one id for every ChatGPT user), so it is - * usually the wrong key on its own. + * Who is calling, usually your user id: `(auth) => auth?.extra?.userId`. Required. Every task + * is owned by its caller, and `undefined` refuses the call. `auth.clientId` is the OAuth app + * (shared by every ChatGPT user), so it is the wrong key. A server with no users of its own + * passes `() => "local"`. */ - principal?: (auth: CallerAuth | undefined) => string | undefined; - /** Rename the two shared tools, e.g. to namespace them next to other servers' tools. */ + principal: PrincipalResolver; + /** Renames the shared tools, e.g. to namespace them. */ toolNames?: { status?: string; cancel?: string }; /** - * Called once per task, right after it reaches `completed`, `failed` or `cancelled`. Only the - * write that performed the transition triggers it, so a redelivery or a repeated cancel never - * calls it twice. - * - * The usual consumer is the `task.finished` event from `@upstash/mcp-toolkit/events`, which - * turns this into a webhook so a client can stop polling. A throw here is logged and swallowed: - * the task is already settled and must stay that way. + * Called once per task, by the write that made it terminal. Throws are logged and swallowed. + * `taskFinishedEvent(...).onSettle` from `@upstash/mcp-toolkit/events` plugs in here. */ onSettle?: (task: Task) => void | Promise; }; export type TaskToolConfig> = { - /** Human-readable title for `tools/list`. */ title?: string; - /** - * What the tool does, for the model. The layer appends one sentence telling the model the call - * returns a task id to poll, so this only needs to describe the work. - */ + /** What the tool does. The layer appends a sentence about polling `task_status`. */ description: string; - /** A Standard Schema (Zod 4, ArkType, Valibot) describing the tool's arguments. */ + /** A Standard Schema (Zod 4, ArkType, Valibot) for the 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"`. */ + /** Status message at creation. Defaults to `"Queued for durable execution"`. */ queuedMessage?: string; - /** Status message set on success. Defaults to `"Completed"`. */ + /** Status message on success. Defaults to `"Completed"`. */ completedMessage?: string; /** - * Derives an idempotency key from the arguments. Two calls from the same caller that produce - * the same key, while the first task is still retained, return the **same** task instead of - * starting a second one — which is what an agent retrying a timed-out tool call needs. - * - * Return `undefined` to opt a call out. To dedupe identical calls outright, return a stable - * serialization of the arguments; to let the model choose, add a field to your schema and - * return it here. + * Derives an idempotency key from the arguments. Two calls by the same caller with the same key + * return the same task while it is retained. Return `undefined` to opt a call out. */ idempotencyKey?: (args: Args) => string | undefined; }; -/** Infers a Standard Schema's parsed output type. */ type InferArgs = Schema extends { readonly "~standard": { types?: { readonly output: infer Output } | undefined }; } @@ -127,127 +86,117 @@ type InferArgs = Schema extends { : 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. - * - * Return an MCP tool result (`content`, optionally `structuredContent`). The model receives it - * from `task_status` once the task completes. + * A task's implementation. The context is {@link TaskContext} plus whatever the dispatcher adds + * (the live `WorkflowContext` on Workflow). Return an MCP tool result. */ export type TaskHandler = ( args: Args, task: TaskContext & TContext, ) => Promise>; +export type TaskDefinition = { readonly name: string }; + export type TaskLayer = { - /** - * Registers a tool whose calls start a task and answer with its handle. The first call on a - * server also registers the shared `task_status` and `task_cancel` tools. - */ - registerTask( - server: McpServer, + /** Declares a task tool and its handler. Call it at module scope, so every instance has it. */ + define( 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. - */ + ): TaskDefinition; + /** Adds every defined task tool, plus `task_status` and `task_cancel`, to a server. */ + register(server: McpServer): void; + /** The dispatcher's delivery endpoint: `export const POST = tasks.createExecuteHandler()`. */ createExecuteHandler(): (request: Request) => Promise; - /** Reads a task record server-side, bypassing ownership checks. */ + /** Reads a task server-side, without an ownership check. */ getTask(taskId: string): Promise; - /** Cancels a task server-side, bypassing ownership checks. Idempotent. */ + /** Cancels a task server-side, without an ownership check. Idempotent. */ cancelTask(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 const tasks = createTaskLayer({ store, dispatcher, principal }); + * tasks.define("generate_report", { description, inputSchema }, handler); // module scope + * + * export function createServer() { + * const server = new McpServer({ name: "reports", version: "1.0.0" }); + * tasks.register(server); + * return server; + * } * ``` */ export function createTaskLayer( options: TaskLayerOptions, ): TaskLayer { - const { store, dispatcher, defaults = {}, principal, onSettle } = options; - - /** Runs the settle hook for a transition this call performed; never throws. */ - async function settled(task: T): Promise { - if (task && onSettle) { - try { - await onSettle(task); - } catch (error) { - console.warn(`[mcp-toolkit] onSettle failed for task ${task.taskId}:`, error); - } - } - return task; - } + const { store, dispatcher, defaults = {}, onSettle } = options; + const principal = requirePrincipal(options.principal, "createTaskLayer"); const toolNames = { status: options.toolNames?.status ?? DEFAULT_TOOL_NAMES.status, cancel: options.toolNames?.cancel ?? DEFAULT_TOOL_NAMES.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(); + // Keyed by tool name: a delivery only carries a task id, and the record names its tool. + const definitions = new Map< + string, + { config: TaskToolConfig; handler: TaskHandler } + >(); const wired = new WeakSet(); - function registerTask( - server: McpServer, + const callerOf = (context: unknown) => principal(authOf(context)); + + async function runSettleHook(task: Task): Promise { + if (!onSettle) return; + try { + await onSettle(task); + } catch (error) { + console.warn(`[mcp-toolkit] onSettle failed for task ${task.taskId}:`, error); + } + } + + async function settle(taskId: string, patch: Parameters[1]) { + const outcome = await store.settle(taskId, patch); + if (outcome?.settled) await runSettleHook(outcome.task); + return outcome; + } + + function define( name: string, config: TaskToolConfig, handler: TaskHandler, TContext>, - ): void { - handlers.set(name, handler as TaskHandler); - if (config.completedMessage) completedMessages.set(name, config.completedMessage); + ): TaskDefinition { + if (name === toolNames.status || name === toolNames.cancel) { + throw new Error(`"${name}" is reserved for the shared task tools`); + } + if (definitions.has(name)) throw new Error(`Task "${name}" is already defined`); + definitions.set(name, { + config: config as unknown as TaskToolConfig, + handler: handler as TaskHandler, + }); + return { name }; + } + + function register(server: McpServer): void { + if (wired.has(server)) return; + wired.add(server); + for (const [name, { config }] of definitions) registerTaskTool(server, name, config); registerSharedTools(server); + } + function registerTaskTool( + server: McpServer, + name: string, + config: TaskToolConfig, + ): void { const callback = async (args: unknown, context: unknown): Promise> => { const owner = callerOf(context); - const key = config.idempotencyKey?.(args as InferArgs); - - // A keyed call gets a deterministic id, so a retry lands on the task the first call made. - // Scoped by owner and tool, so two callers' keys can never collide. - const taskId = key === undefined ? randomUUID() : deterministicId(owner, name, key); - if (key !== undefined) { - const existing = await store.get(taskId); - if (existing) return startedResult(existing, toolNames, true); - } - + if (owner === undefined) return notAuthenticatedResult(); + const key = config.idempotencyKey?.(args); + // A keyed call gets a deterministic id scoped to its caller, so a retry finds the first task. + const taskId = + key === undefined + ? crypto.randomUUID() + : (await sha256Hex(JSON.stringify([owner, name, key]))).slice(0, 32); const now = new Date().toISOString(); const task: Task = { taskId, @@ -255,21 +204,36 @@ export function createTaskLayer( statusMessage: config.queuedMessage ?? "Queued for durable execution", createdAt: now, lastUpdatedAt: now, - ttlMs: config.ttlMs ?? defaults.ttlMs ?? DEFAULT_TTL_MS, + // `null` means unlimited, so `??` would wrongly replace it. + ttlMs: + config.ttlMs !== undefined + ? config.ttlMs + : defaults.ttlMs !== undefined + ? defaults.ttlMs + : DEFAULT_TTL_MS, pollIntervalMs: config.pollIntervalMs ?? defaults.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, name, args, - ...(owner !== undefined && { owner }), + owner, }; - // The record must be durable before the handle goes out, because the model may poll it - // against another instance the moment it has the id. Dispatch second, so a queue that - // accepts a task can always find its record. Two racing keyed calls both get here at - // worst: they write the same record, and the dispatchers dedupe on the task id. - await store.create(task); - const dispatchId = await dispatcher.dispatch(task.taskId); - const saved = dispatchId ? await store.update(task.taskId, { dispatchId }) : task; + // The record is durable before the id goes out, and before the work is queued. + const existing = await store.create(task); + if (existing) return startedResult(existing, toolNames, true); + let dispatchId: string | undefined; + try { + dispatchId = await dispatcher.dispatch(task); + } catch (error) { + // Nothing will ever run this record: fail it rather than leave it `working` until its TTL. + await settle(taskId, { + status: "failed", + statusMessage: "Could not be queued", + error: { code: INTERNAL_ERROR, message: "The task could not be dispatched" }, + }).catch(() => undefined); + throw error; + } + const saved = dispatchId ? await store.update(taskId, { dispatchId }) : task; return startedResult(saved, toolNames, false); }; @@ -286,10 +250,7 @@ export function createTaskLayer( ); } - /** Registers `task_status` and `task_cancel` on a server, once. */ function registerSharedTools(server: McpServer): void { - if (wired.has(server)) return; - wired.add(server); const inputSchema = z.object({ taskId: z.string().describe("The taskId returned when the task was started."), }); @@ -306,7 +267,9 @@ export function createTaskLayer( annotations: { readOnlyHint: true, idempotentHint: true }, }, (async ({ taskId }: { taskId: string }, context: unknown) => { - const task = await owned(taskId, context); + const caller = callerOf(context); + if (caller === undefined) return notAuthenticatedResult(); + const task = await owned(taskId, caller); return task ? statusResult(task) : unknownTaskResult(taskId); }) as never, ); @@ -322,7 +285,9 @@ export function createTaskLayer( annotations: { destructiveHint: true, idempotentHint: true }, }, (async ({ taskId }: { taskId: string }, context: unknown) => { - if (!(await owned(taskId, context))) return unknownTaskResult(taskId); + const caller = callerOf(context); + if (caller === undefined) return notAuthenticatedResult(); + if (!(await owned(taskId, caller))) return unknownTaskResult(taskId); const task = await cancelTask(taskId); return task ? statusResult(task) : unknownTaskResult(taskId); }) as never, @@ -330,91 +295,65 @@ export function createTaskLayer( } async function cancelTask(taskId: string): Promise { - const task = await read(taskId); - if (!task) return null; - // 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 cancelled = await settled( - await store.settle(taskId, { status: "cancelled", statusMessage: "Cancelled by client" }), - ); - const dispatchId = cancelled?.dispatchId ?? task.dispatchId; - if (dispatchId) { - // A message already in flight cannot be recalled; that is why cancellation is cooperative, - // and why the handler still checks `isCancelled()`. - await dispatcher.cancel(dispatchId).catch(() => undefined); + const outcome = await settle(taskId, { + status: "cancelled", + statusMessage: "Cancelled by client", + }); + if (!outcome) return null; + // Also stop pending redeliveries. A delivery already running can't be recalled, which is why + // handlers check `isCancelled()`. + if (outcome.settled && outcome.task.dispatchId) { + await dispatcher.cancel(outcome.task.dispatchId).catch(() => undefined); } - return cancelled ?? (await read(taskId)); + return outcome.task; } - async function executeTask( - taskId: string, - context?: TContext, - journal?: TaskJournal, - ): Promise { + /** + * Runs a delivered task. A throw propagates and leaves the task `working`: only the transport + * knows whether it will deliver again, so only it records the final failure. + */ + async function executeTask(taskId: string, context?: TContext, journal?: TaskJournal) { const task = await read(taskId); - // Expired, or never existed: nothing to run, and nothing a retry could fix. - if (!task) return null; - - // 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) { + // Expired or unknown, or a redelivery of a finished task: nothing to do. + if (!task || isTerminal(task.status)) return; + const definition = definitions.get(task.name); + if (!definition) { throw new Error( - `No task handler registered for "${task.name}". Register it on every instance that serves the execute endpoint.`, + `No task handler defined for "${task.name}". Call tasks.define(...) at module scope, in a module the execute route imports.`, ); } - // 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. + // Journaled writes are named by call order, which a replay reproduces. 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)( + const result = await (definition.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 completed = await settled( - await store.settle(taskId, { - status: "completed", - statusMessage: completedMessages.get(task.name) ?? "Completed", - result, - }), - ); - return completed ?? (await store.get(taskId)); + // A cancel that landed meanwhile wins: settle refuses the second terminal write. + await settle(taskId, { + status: "completed", + statusMessage: definition.config.completedMessage ?? "Completed", + result, + }); } - async function failTask(taskId: string, error: TaskError): Promise { - return await settled( - await store.settle(taskId, { status: "failed", statusMessage: "Execution failed", error }), - ); + async function failTask(taskId: string, error: TaskError) { + await settle(taskId, { status: "failed", statusMessage: "Execution failed", error }); } - /** Reads a task, folding "unknown" and "expired" into null. */ + /** Reads a task, folding a store's {@link UnknownTaskError} into null. */ async function read(taskId: string): Promise { try { return await store.get(taskId); @@ -424,56 +363,31 @@ export function createTaskLayer( } } - /** - * Reads a task the caller is allowed to see. A task owned by someone else is reported as - * unknown rather than forbidden, so an id cannot be probed for existence. - */ - async function owned(taskId: string, context: unknown): Promise { + /** Another caller's task reads as unknown, so ids cannot be probed. */ + async function owned(taskId: string, caller: string): Promise { const task = await read(taskId); - if (!task) return null; - if (task.owner === undefined || task.owner === null) return task; - // Compared as strings: Redis' auto-deserialization turns a numeric-looking owner into a number. - return String(task.owner) === callerOf(context) ? task : null; - } - - function callerOf(context: unknown): string | undefined { - if (!principal) return undefined; - const auth = (context as { http?: { authInfo?: CallerAuth } } | undefined)?.http?.authInfo; - return principal(auth); + return task && task.owner === caller ? task : null; } 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.", + "This dispatcher runs tasks in-process and has no delivery endpoint. Use QStashDispatcher or WorkflowDispatcher.", ); } 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: read, - cancelTask, - store, - dispatcher, - }; + return { define, register, createExecuteHandler, getTask: read, cancelTask }; } -/** What the starting tool answers: the handle, and a sentence telling the model what to do next. */ function startedResult( task: Task, toolNames: { status: string }, deduplicated: boolean, ): Record { - const wire = toWire(task); const lead = deduplicated ? `Task ${task.taskId} already exists for this request (status: ${task.status}).` : `Started task ${task.taskId}.`; @@ -486,18 +400,14 @@ function startedResult( `in about ${seconds(task.pollIntervalMs)} to check on it.`, }, ], - structuredContent: wire, + structuredContent: toWire(task), }; } -/** - * What `task_status` and `task_cancel` answer. A completed task hands back its own result's - * `content`, so the model reads the answer exactly as if the tool had run synchronously. - */ +/** A status line, followed by the task's own result content once it completed. */ function statusResult(task: Task): Record { const wire = toWire(task); const line = `Task ${task.taskId} is ${task.status}${task.statusMessage ? `: ${task.statusMessage}` : "."}`; - if (task.status === "completed") { const content = Array.isArray(task.result?.content) ? task.result.content : []; return { content: [{ type: "text", text: line }, ...content], structuredContent: wire }; @@ -513,6 +423,15 @@ function statusResult(task: Task): Record { return { content: [{ type: "text", text: line + hint }], structuredContent: wire }; } +function notAuthenticatedResult(): Record { + return { + isError: true, + content: [ + { type: "text", text: "Not authenticated: this server could not identify the caller." }, + ], + }; +} + function unknownTaskResult(taskId: string): Record { return { isError: true, @@ -528,22 +447,9 @@ function unknownTaskResult(taskId: string): Record { const seconds = (ms: number | undefined) => `${Math.max(1, Math.round((ms ?? DEFAULT_POLL_INTERVAL_MS) / 1000))}s`; -/** A stable, opaque task id for an idempotency key. */ -function deterministicId(owner: string | undefined, tool: string, key: string): string { - return createHash("sha256") - .update(JSON.stringify([owner ?? null, tool, key])) - .digest("hex") - .slice(0, 32); -} - /** - * 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. + * Merges the task context into the transport's, as one object. Assigned onto the instance rather + * than spread: a `WorkflowContext` keeps its methods on the prototype. */ function mergeContext( taskContext: TaskContext, diff --git a/packages/mcp-toolkit/src/tasks/index.ts b/packages/mcp-toolkit/src/tasks/index.ts index 8d02f3a..7e60dce 100644 --- a/packages/mcp-toolkit/src/tasks/index.ts +++ b/packages/mcp-toolkit/src/tasks/index.ts @@ -1,30 +1,34 @@ /** - * `@upstash/mcp-toolkit/tasks` — durable long-running tools for MCP servers on the official TypeScript SDK. - * - * The core here is storage-agnostic. The Upstash Redis + QStash backends live behind the - * `@upstash/mcp-toolkit/tasks/upstash` entry point, so bringing your own store costs you nothing. + * `@upstash/mcp-toolkit/tasks`: long-running tools for MCP servers on the official TypeScript SDK. + * The Upstash backends are in `@upstash/mcp-toolkit/tasks/upstash`. */ export { createTaskLayer, toWire, DEFAULT_TOOL_NAMES, type CallerAuth, + type TaskDefinition, type TaskHandler, type TaskLayer, type TaskLayerOptions, type TaskToolConfig, } from "./core.js"; +export type { PrincipalResolver } from "../shared/auth.js"; + export { + dispatchKey, isTerminal, TERMINAL_STATUSES, UnknownTaskError, + type SettleResult, type Task, type TaskContext, type TaskDispatcher, + type TaskEndpoints, type TaskError, + type TaskJournal, type TaskPatch, - type TaskEndpoints, type TaskStatus, type TaskStore, type TerminalTaskPatch, diff --git a/packages/mcp-toolkit/src/tasks/types.ts b/packages/mcp-toolkit/src/tasks/types.ts index 191e070..bd7fe20 100644 --- a/packages/mcp-toolkit/src/tasks/types.ts +++ b/packages/mcp-toolkit/src/tasks/types.ts @@ -1,41 +1,23 @@ /** - * 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 two seams of the tasks runtime: a {@link TaskStore} owns the task record, a + * {@link TaskDispatcher} owns the execution. The core depends only on these. */ -/** - * A task's five states, borrowed from the `io.modelcontextprotocol/tasks` extension so a native - * adapter can serve the same records later. `completed`, `failed` and `cancelled` are terminal: - * once a task reaches one, its status never changes again. - */ +/** A task's states, from the `io.modelcontextprotocol/tasks` extension. The last three are terminal. */ 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. - */ +/** Thrown by a store for an unknown or expired task. Not an MCP error, so stores need no SDK. */ export class UnknownTaskError extends Error { override readonly name = "UnknownTaskError"; constructor(readonly taskId: string) { @@ -50,11 +32,7 @@ export type TaskError = { data?: unknown; }; -/** - * Exactly the object the model sees in a task tool's `structuredContent` — the same shape as the - * Tasks extension's task object. Everything the server keeps for itself lives on {@link Task} - * instead, and is stripped on the way out. - */ +/** What the model sees in `structuredContent`: the extension's task object. */ export type WireTask = { taskId: string; status: TaskStatus; @@ -65,166 +43,102 @@ export type WireTask = { lastUpdatedAt: string; /** Retention window in milliseconds. `null` means unlimited. */ ttlMs: number | null; - /** How long the model should wait between `task_status` polls. */ pollIntervalMs?: number; - /** Present once the task is `completed`: the tool result, inline. */ + /** The tool result, once `completed`. */ result?: Record; - /** Present once the task is `failed`. */ + /** Once `failed`. */ error?: TaskError; }; -/** - * The stored task: the wire object plus the fields the server needs and the model never sees — - * which tool to run, what to run it with, which dispatch to cancel, and who owns it. - */ +/** The stored task: the wire object plus server-only fields the model never sees. */ export type Task = WireTask & { - /** The registered task name, so the executor knows which handler to run. */ + /** The defined task name, which picks the handler. */ name: string; - /** The validated tool input, replayed into the handler on delivery. */ + /** The validated tool input. */ args: unknown; - /** The dispatcher's handle for the pending delivery, so cancel can stop retries. */ + /** The dispatcher's handle, so cancel can stop pending deliveries. */ dispatchId?: string; - /** - * The caller that started the task, from `TaskLayerOptions.principal`. When set, only the same - * caller can read or cancel it through the tools. - */ - owner?: string; + /** The caller that started the task. Only they can read or cancel it. */ + owner: string; }; -/** The fields a caller may patch on a stored task. */ -export type TaskPatch = Partial>; +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 model may immediately poll it against a - * different instance. So {@link create} must have committed before it resolves. - */ +/** What {@link TaskStore.settle} returns for an existing task. */ +export type SettleResult = { + /** The task after the call: settled by it, or unchanged because it was already terminal. */ + task: Task; + /** True only for the call that performed the terminal transition. */ + settled: boolean; +}; + export interface TaskStore { - /** Durably persists a new task before resolving. */ - create(task: Task): Promise; + /** + * Durably creates a task unless one with that id exists, atomically. Resolves `null` when it + * created the task, or the existing task otherwise. + */ + create(task: Task): Promise; - /** Returns the latest durable state of a task, or `null` when it is absent or expired. */ + /** The latest 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}. + * Applies a non-terminal patch without extending the TTL. A terminal task is returned + * unchanged. Throws {@link UnknownTaskError} when the task is missing. */ 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 `task_cancel` and the executor finishing at the same moment. First - * terminal write wins, and a late `completed` can never overwrite a `cancelled`. + * Atomically moves a non-terminal task to a terminal state. First terminal write wins, so a late + * `completed` cannot overwrite a `cancelled`. Resolves `null` when the task is missing. */ - settle(taskId: string, patch: TerminalTaskPatch): Promise; + 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. + * Durable, at-least-once execution. `TContext` is what the transport gives a running handler: + * nothing for a queue, the `WorkflowContext` for Upstash Workflow. */ 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. + * Durably accepts a delivery and returns a handle for {@link cancel}. Must be idempotent per + * task record: dedupe on {@link dispatchKey}, not the task id alone, because a keyed task id is + * reused once its record expires. */ - dispatch(taskId: string): Promise; + dispatch(task: Task): Promise; - /** Idempotently stops a pending delivery and its future retries, when the transport can. */ + /** Stops pending deliveries, when the transport can. Idempotent. */ 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. - */ + /** Receives the layer's entry points when passed to `createTaskLayer`. */ 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. - */ + /** The transport's delivery endpoint, for dispatchers that deliver over HTTP. */ 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. - */ + /** Runs a delivered task. Rejects if the handler threw, which means "deliver again". */ 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. - */ + /** Records a terminal failure, once the transport has stopped retrying. */ 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. - */ +/** How a replaying transport runs a side effect once. The core wraps `task.update` with it. */ 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}. - */ +/** What every handler is handed, whatever the transport. */ 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. */ + /** Publishes a progress line that the next `task_status` 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. - */ + /** True once the client cancelled, or the record expired. Cooperative: check at step boundaries. */ isCancelled(): Promise; }; + +/** A dedupe key unique to one task record: `-`. */ +export const dispatchKey = (task: Pick): string => + `${task.taskId}-${Date.parse(task.createdAt)}`; diff --git a/packages/mcp-toolkit/src/tasks/upstash.ts b/packages/mcp-toolkit/src/tasks/upstash.ts index 3731c26..44e24fc 100644 --- a/packages/mcp-toolkit/src/tasks/upstash.ts +++ b/packages/mcp-toolkit/src/tasks/upstash.ts @@ -1,13 +1,6 @@ /** - * 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. + * The Upstash task backends: Redis for the record, QStash or Upstash Workflow for the work. + * Needs `@upstash/redis`, `@upstash/qstash` and `@upstash/workflow` installed. */ export { RedisTaskStore, diff --git a/packages/mcp-toolkit/src/telemetry.test.ts b/packages/mcp-toolkit/src/telemetry.test.ts new file mode 100644 index 0000000..86beeef --- /dev/null +++ b/packages/mcp-toolkit/src/telemetry.test.ts @@ -0,0 +1,50 @@ +import { Client as QStashClient } from "@upstash/qstash"; +import { Client as WorkflowClient } from "@upstash/workflow"; +import { afterEach, describe, expect, it } from "vitest"; +import { addQStashTelemetry, SDK_TELEMETRY } from "./telemetry.js"; + +const sdkHeader = (client: unknown) => + (client as { http: { telemetryHeaders: Headers } }).http.telemetryHeaders.get( + "Upstash-Telemetry-Sdk", + ); + +describe("addQStashTelemetry", () => { + const saved = process.env.UPSTASH_DISABLE_TELEMETRY; + afterEach(() => { + if (saved === undefined) delete process.env.UPSTASH_DISABLE_TELEMETRY; + else process.env.UPSTASH_DISABLE_TELEMETRY = saved; + }); + + it("appends the package tag to a QStash client's header, once", () => { + const client = new QStashClient({ token: "t" }); + addQStashTelemetry(client); + addQStashTelemetry(client); + expect(sdkHeader(client)).toMatch(/^upstash-qstash-js@[^,]+,@upstash\/mcp-toolkit@/); + expect( + sdkHeader(client) + ?.split(",") + .filter((tag) => tag === SDK_TELEMETRY), + ).toHaveLength(1); + }); + + it("reaches the QStash client inside a Workflow client", () => { + const client = new WorkflowClient({ token: "t" }); + addQStashTelemetry(client); + expect(sdkHeader((client as unknown as { client: unknown }).client)).toContain(SDK_TELEMETRY); + }); + + it("leaves a client alone when telemetry is off", () => { + const optedOut = new QStashClient({ token: "t", enableTelemetry: false }); + addQStashTelemetry(optedOut); + expect(sdkHeader(optedOut)).toBeNull(); + + const client = new QStashClient({ token: "t" }); + addQStashTelemetry(client, { enabled: false }); + expect(sdkHeader(client)).not.toContain(SDK_TELEMETRY); + + process.env.UPSTASH_DISABLE_TELEMETRY = "1"; + const viaEnv = new QStashClient({ token: "t" }); + addQStashTelemetry(viaEnv); + expect(sdkHeader(viaEnv) ?? "").not.toContain(SDK_TELEMETRY); + }); +}); diff --git a/packages/mcp-toolkit/src/telemetry.ts b/packages/mcp-toolkit/src/telemetry.ts index 055b9ff..2ef8261 100644 --- a/packages/mcp-toolkit/src/telemetry.ts +++ b/packages/mcp-toolkit/src/telemetry.ts @@ -52,3 +52,36 @@ export const addTelemetry = ( // telemetry must never break the client } }; + +const SDK_HEADER = "Upstash-Telemetry-Sdk"; + +type TelemetryCapableQStash = { http?: { telemetryHeaders?: Headers } }; + +/** + * Appends this package's tag to a QStash client's telemetry header, producing + * `upstash-qstash-js@2.12.0,@upstash/mcp-toolkit@0.1.0` — the same shape as the Redis header. + * + * Accepts a QStash `Client`, or an Upstash Workflow `Client`, which wraps one as `.client`. The + * header only exists when the client itself has telemetry on, so a client built with + * `enableTelemetry: false` stays untagged. Opt out here with `enabled: false` or + * `UPSTASH_DISABLE_TELEMETRY`. + */ +export const addQStashTelemetry = ( + client: unknown, + options: { sdk?: string; enabled?: boolean } = {}, +): void => { + const { sdk = SDK_TELEMETRY, enabled = true } = options; + if (!enabled || getSafeEnv().UPSTASH_DISABLE_TELEMETRY) return; + if (!client || typeof client !== "object") return; + const qstash = ("http" in client ? client : (client as { client?: unknown }).client) as + | TelemetryCapableQStash + | undefined; + try { + const headers = qstash?.http?.telemetryHeaders; + const current = headers?.get(SDK_HEADER); + if (!headers || !current || current.split(",").includes(sdk)) return; + headers.set(SDK_HEADER, `${current},${sdk}`); + } catch { + // telemetry must never break the client + } +}; diff --git a/packages/mcp-toolkit/src/test-support.ts b/packages/mcp-toolkit/src/test-support.ts index 754013c..a10d28d 100644 --- a/packages/mcp-toolkit/src/test-support.ts +++ b/packages/mcp-toolkit/src/test-support.ts @@ -8,6 +8,8 @@ import { randomUUID } from "node:crypto"; import { config } from "dotenv"; import { Redis } from "@upstash/redis"; +import { InlineTaskDispatcher } from "./tasks/backends/memory.js"; +import type { Task, TaskDispatcher, TaskEndpoints, TaskError, TaskJournal } from "./tasks/types.js"; // Load repo-root .env (no-op if already loaded or absent). config(); @@ -39,3 +41,57 @@ export async function cleanupKeys(redis: Redis, prefix: string): Promise { /** Resolves after `ms`. */ export const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** An inline dispatcher that counts what it was handed. */ +export class CountingDispatcher extends InlineTaskDispatcher { + dispatched = 0; + override async dispatch(task: Task): Promise { + this.dispatched += 1; + return await super.dispatch(task); + } +} + +/** + * A dispatcher that records dispatches and runs nothing on its own, so a test drives each + * delivery by hand, the way a queue would. + */ +export class ManualDispatcher implements TaskDispatcher { + readonly dispatched: Task[] = []; + readonly cancelled: string[] = []; + /** Makes the next `dispatch` throw this error. */ + failNext: Error | undefined; + private endpoints: TaskEndpoints | undefined; + + attach(endpoints: TaskEndpoints): void { + this.endpoints = endpoints; + } + + async dispatch(task: Task): Promise { + if (this.failNext) { + const error = this.failNext; + this.failNext = undefined; + throw error; + } + this.dispatched.push(task); + return `msg_${this.dispatched.length}`; + } + + async cancel(dispatchId: string): Promise { + this.cancelled.push(dispatchId); + } + + /** Delivers a task, as the transport would. */ + run(taskId: string, context?: TContext, journal?: TaskJournal): Promise { + return this.attached().run(taskId, context as TContext, journal); + } + + /** Reports that the transport gave up, as a failure callback would. */ + fail(taskId: string, error: TaskError): Promise { + return this.attached().fail(taskId, error); + } + + private attached(): TaskEndpoints { + if (!this.endpoints) throw new Error("ManualDispatcher is not attached"); + return this.endpoints; + } +} From f9e4b6c9cd757ad8a55f64ac8f2578b1ecc5a98f Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 11:54:00 +0000 Subject: [PATCH 20/33] DX-3022: mcp-toolkit: per-record task.finished ids, batched subscription lookups - task.finished uses evt_task_${dispatchKey(task)}: a keyed task re-created after its record expires reuses the task id, and QStash (10-minute dedup window) or a host deduping on webhook-id would drop the second task's event - RedisSubscriptionStore.find splits its ZRANGE pipeline and MGET into batches of at most 1,000 commands, so emit({ owners }) with a large team stays bounded Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK --- CLAUDE.md | 5 +++ .../src/events/backends/qstash.test.ts | 10 ++++++ .../mcp-toolkit/src/events/backends/qstash.ts | 32 +++++++++++++---- packages/mcp-toolkit/src/events/core.test.ts | 34 ++++++++++++++++++- packages/mcp-toolkit/src/events/tasks.ts | 7 ++-- 5 files changed, 78 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7e686c0..0014382 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -734,6 +734,11 @@ implements TanStack AI's own backend contracts (see its section below) — keep `${taskId}-${createdAt ms}`. QStash remembers a `deduplicationId` for 10 minutes and Workflow refuses a reused `workflowRunId`, while a keyed task id comes back once its 5-minute record expires. A failed `dispatch` settles the fresh record `failed` ("Could not be queued") and rethrows. + The `task.finished` event id is `evt_task_${dispatchKey(task)}` for the same reason: QStash and + hosts dedupe on it. +- **`RedisSubscriptionStore.find` batches:** one `ZRANGE` per owner × argument subset (up to 256 + per owner), split into pipelines and `MGET`s of at most 1,000 commands, so `emit({ owners })` + with a large team never sends one giant request. - **No secret defaults:** `MCP_EVENTS_SECRET_KEY` has no fallback, demo included; the event layer resolves it on first use (not at construction, so builds without env still work) and a missing key throws rather than dropping deliveries. diff --git a/packages/mcp-toolkit/src/events/backends/qstash.test.ts b/packages/mcp-toolkit/src/events/backends/qstash.test.ts index 8497942..04a2e41 100644 --- a/packages/mcp-toolkit/src/events/backends/qstash.test.ts +++ b/packages/mcp-toolkit/src/events/backends/qstash.test.ts @@ -67,6 +67,16 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { expect(both.map((s) => s.id).sort()).toEqual([alice.id, bob.id].sort()); }); + it("splits a lookup across many owners into bounded pipelines", async () => { + // 1,500 owners is 1,500 ZRANGEs: more than one pipeline's worth. + const owners = Array.from({ length: 1500 }, (_, i) => `member-${i}`); + const picked = [owners[3]!, owners[1100]!, owners[1499]!]; + const subs = picked.map((owner) => makeSub({ event: "team", args: {}, owner })); + for (const sub of subs) await store.put(sub); + const found = await store.find("team", owners, ["{}"]); + expect(found.map((s) => s.id).sort()).toEqual(subs.map((s) => s.id).sort()); + }); + it("refreshing replaces the record and extends the index", async () => { const sub = makeSub({ event: "refresh", expiresAt: Date.now() + 10_000 }); await store.put(sub); diff --git a/packages/mcp-toolkit/src/events/backends/qstash.ts b/packages/mcp-toolkit/src/events/backends/qstash.ts index d73947a..6ace3ad 100644 --- a/packages/mcp-toolkit/src/events/backends/qstash.ts +++ b/packages/mcp-toolkit/src/events/backends/qstash.ts @@ -23,6 +23,9 @@ import type { export const DEFAULT_EVENTS_PREFIX = "mcp-events:"; +/** The most commands one pipeline or `MGET` carries; `find` splits larger lookups. */ +const MAX_BATCH = 1000; + export type RedisSubscriptionStoreConfig = { /** The Upstash Redis client. Defaults to one from `UPSTASH_REDIS_REST_URL` / `_TOKEN`. */ redis?: Redis; @@ -96,13 +99,24 @@ export class RedisSubscriptionStore implements SubscriptionStore { argsKeys.map((argsKey) => this.indexKey({ event, owner, argsKey })), ), ); + // One ZRANGE per owner and argument subset (up to 256 per owner), so a large `owners` list is + // split into bounded pipelines rather than sent as one huge request. const now = Date.now(); - const pipeline = this.redis().pipeline(); - for (const key of keys) pipeline.zrange(key, now, "+inf", { byScore: true }); - const ids = [...new Set((await pipeline.exec()).flat().map(String))]; - if (ids.length === 0) return []; - const records = await this.redis().mget(...ids.map((id) => this.subKey(id))); - return records.map(parse).filter((sub): sub is Subscription => sub !== null); + const ids = new Set(); + for (const batch of chunks(keys, MAX_BATCH)) { + const pipeline = this.redis().pipeline(); + for (const key of batch) pipeline.zrange(key, now, "+inf", { byScore: true }); + for (const id of (await pipeline.exec()).flat()) ids.add(String(id)); + } + const found: Subscription[] = []; + for (const batch of chunks([...ids], MAX_BATCH)) { + const records = await this.redis().mget(...batch.map((id) => this.subKey(id))); + for (const record of records) { + const sub = parse(record); + if (sub) found.push(sub); + } + } + return found; } /** The key a subscription is stored under. */ @@ -121,6 +135,12 @@ export class RedisSubscriptionStore implements SubscriptionStore { } } +function chunks(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + function parse(raw: unknown): Subscription | null { if (raw === null || raw === undefined) return null; // @upstash/redis deserializes JSON values automatically unless told not to. diff --git a/packages/mcp-toolkit/src/events/core.test.ts b/packages/mcp-toolkit/src/events/core.test.ts index 8563aca..b7c1b4b 100644 --- a/packages/mcp-toolkit/src/events/core.test.ts +++ b/packages/mcp-toolkit/src/events/core.test.ts @@ -493,16 +493,48 @@ describe("task.finished", () => { expect(deliveries[0]?.url).toBe(alice.url); expect(deliveries[0]?.body).toMatchObject({ name: "task.finished", - eventId: `evt_task_${taskId}`, + eventId: expect.stringMatching(new RegExp(`^evt_task_${taskId}-\\d+$`)), data: { taskId, status: "completed", result: { content: [{ type: "text", text: "done" }] } }, }); + // The event id is per task record, not per task id: a keyed task re-created after its record + // expired reuses the id, and must not be dropped as a duplicate of the first one's event. + expect(deliveries[0]?.body.eventId).not.toBe(`evt_task_${taskId}`); + // A cancel after completion settles nothing, so nothing fires. await tasks.cancelTask(taskId); expect(receiver.deliveries()).toHaveLength(1); }); }); +describe("task.finished event ids", () => { + it("differ for a keyed task re-created after its record expired", async () => { + const { events } = setup(); + const taskFinished = taskFinishedEvent(events); + const ids: (string | undefined)[] = []; + (taskFinished.event as { emit: unknown }).emit = async ( + _payload: unknown, + options: { eventId?: string }, + ) => { + ids.push(options.eventId); + return { eventId: options.eventId ?? "", matched: 0 }; + }; + const record = { + taskId: "keyed", + status: "completed" as const, + lastUpdatedAt: "2026-10-08T10:00:01.000Z", + ttlMs: 300_000, + name: "slow", + args: {}, + owner: "alice", + }; + await taskFinished.onSettle({ ...record, createdAt: "2026-10-08T10:00:00.000Z" }); + await taskFinished.onSettle({ ...record, createdAt: "2026-10-08T10:06:00.000Z" }); + expect(ids).toHaveLength(2); + expect(ids[0]).not.toBe(ids[1]); + }); +}); + describe("helpers", () => { it("enumerates every subset of the emitted arguments", () => { expect(matchKeys({ a: 1, b: 2 }).sort()).toEqual( diff --git a/packages/mcp-toolkit/src/events/tasks.ts b/packages/mcp-toolkit/src/events/tasks.ts index cb4c594..e32451d 100644 --- a/packages/mcp-toolkit/src/events/tasks.ts +++ b/packages/mcp-toolkit/src/events/tasks.ts @@ -9,7 +9,7 @@ * A host subscribes with no arguments for every task its user starts, or with a `taskId`. */ import * as z from "zod"; -import type { Task } from "../tasks/types.js"; +import { dispatchKey, type Task } from "../tasks/types.js"; import { EventPayloadTooLargeError, type EventLayer } from "./core.js"; const taskFinishedInput = z.object({ @@ -58,8 +58,9 @@ export function taskFinishedEvent( const emitOptions = { owner: task.owner, args: { taskId: task.taskId }, - // Stable, so a repeated settle hook cannot deliver twice. - eventId: `evt_task_${task.taskId}`, + // Stable per task record, so a repeated settle hook cannot deliver twice, while a keyed task + // re-created after expiry (same id, new createdAt) is not mistaken for a duplicate. + eventId: `evt_task_${dispatchKey(task)}`, }; try { await event.emit(payload, emitOptions); From 01c88c9fce4a6d59c39314662993d35ad523cd23 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 11:59:44 +0000 Subject: [PATCH 21/33] DX-3022: mcp-toolkit: principal receives { auth, request } and may be async - principal gets the verified AuthInfo and the raw request (SDK v2's ctx.http.authInfo and ctx.http.req), and may return a promise, so apps that authenticate with a cookie or session can look it up - authorize receives { principal, auth, request } - README explains what AuthInfo is and that the app's route populates it via handler.fetch(request, { authInfo }); the SDK never derives it from headers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK --- CLAUDE.md | 7 ++- .../mcp-toolkit-demo/app/lib/qstash-server.ts | 2 +- .../app/lib/workflow-server.ts | 2 +- packages/mcp-toolkit/README.md | 47 ++++++++++++++----- packages/mcp-toolkit/src/events/core.test.ts | 14 +++++- packages/mcp-toolkit/src/events/core.ts | 15 +++--- packages/mcp-toolkit/src/events/index.ts | 1 + packages/mcp-toolkit/src/shared/auth.ts | 37 +++++++++++---- packages/mcp-toolkit/src/tasks/core.test.ts | 17 ++++++- packages/mcp-toolkit/src/tasks/core.ts | 15 +++--- packages/mcp-toolkit/src/tasks/index.ts | 1 + 11 files changed, 115 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0014382..f5b6596 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -701,8 +701,11 @@ implements TanStack AI's own backend contracts (see its section below) — keep only `/api/execute` threw "No task handler" and every delivery 500'd — don't bring it back, and don't reintroduce the top-level `createServer();` workaround. - **Ownership (`principal` is required, both layers).** `createTaskLayer` / `createEventLayer` - throw without it. It receives `ctx.http.authInfo` (where SDK v2 puts transport auth) and stamps - `task.owner` / `subscription.owner`. Returning `undefined` = not authenticated: the call is + throw without it. It receives `{ auth, request }` (SDK v2's `ctx.http.authInfo` and + `ctx.http.req`), may be async, and stamps `task.owner` / `subscription.owner`. `auth` is only what + the app's route passed to `handler.fetch(request, { authInfo })` after verifying the token: the + SDK never derives it from headers. `request` is unverified, for cookie/session apps; docs warn + against trusting caller-set headers. `authorize` gets `{ principal, auth, request }`. Returning `undefined` = not authenticated: the call is refused (`isError` "Not authenticated" for tools, reason `not_authenticated` for events/*) — no anonymous mode, fail closed; a stored task without an owner answers nobody. Single-tenant servers pass `principal: () => "local"`. `task_status` / `task_cancel` report a non-owned task as diff --git a/examples/mcp-toolkit-demo/app/lib/qstash-server.ts b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts index a2b5af1..75bc1f5 100644 --- a/examples/mcp-toolkit-demo/app/lib/qstash-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts @@ -29,7 +29,7 @@ export const tasks = createTaskLayer({ defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, // Who is calling. The demo has no login, so every caller is the same user — said explicitly, // because there is no anonymous default. A real server returns its user id from `auth`: - // principal: (auth) => auth?.extra?.userId as string | undefined + // principal: ({ auth }) => auth?.extra?.userId as string | undefined principal: () => "demo-user", }); diff --git a/examples/mcp-toolkit-demo/app/lib/workflow-server.ts b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts index 9f9c7ea..b8c00e8 100644 --- a/examples/mcp-toolkit-demo/app/lib/workflow-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts @@ -31,7 +31,7 @@ export const tasks = createTaskLayer({ defaults: { ttlMs: 3_600_000, pollIntervalMs: 2_000 }, // Who is calling. The demo has no login, so every caller is the same user — said explicitly, // because there is no anonymous default. A real server returns its user id from `auth`: - // principal: (auth) => auth?.extra?.userId as string | undefined + // principal: ({ auth }) => auth?.extra?.userId as string | undefined principal: () => "demo-user", }); diff --git a/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md index 4f6345b..aa19cc2 100644 --- a/packages/mcp-toolkit/README.md +++ b/packages/mcp-toolkit/README.md @@ -46,7 +46,7 @@ export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), // Who is calling: your user id from the request's auth. Required — see below. - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, }); // Module scope: every instance knows the handler, including an /api/execute instance that never @@ -123,10 +123,33 @@ running code only stops where it checks. ### Who is calling: `principal` -`principal` is required. It receives the `AuthInfo` your auth middleware attached to the request -and returns a stable caller id. Each task records it as its owner, and `task_status` / -`task_cancel` answer only for the caller who started the task — another caller's id reads exactly -like an unknown one. +`principal` is required. It maps each call to a stable caller id, usually your user id. Each task +records it as its owner, and `task_status` / `task_cancel` answer only for the caller who started +the task; another caller's id reads exactly like an unknown one. The same function owns event +subscriptions. + +It receives `{ auth, request }`: + +- **`auth`** is the MCP SDK's `AuthInfo`: `{ token, clientId, scopes, expiresAt?, extra? }`. The + SDK never fills it in from headers. **Your route does**, after verifying the bearer token with + your OAuth provider, by passing it to the handler: + + ```ts + // app/api/mcp/route.ts + export async function POST(request: Request) { + const authInfo = await verifyToken(request); // Clerk, WorkOS, Auth0, your own + if (!authInfo) return new Response("Unauthorized", { status: 401 }); + return handler.fetch(request, { authInfo }); + } + ``` + + `extra` is free-form: put the user id there in `verifyToken`, and read it back with + `principal: ({ auth }) => auth?.extra?.userId as string | undefined`. Because the token was + already checked, this is the path to prefer. + +- **`request`** is the raw HTTP request, for apps that authenticate with a cookie or session + instead of an `AuthInfo`. It is unverified, so check the session yourself (`principal` may be + async), and never trust a header like `x-user-id` that the caller can set. There is no anonymous mode. When `principal` returns `undefined`, the call is refused with "Not authenticated". A server with no users of its own (a local tool, a demo) says so explicitly: @@ -204,7 +227,7 @@ import type { WorkflowContext } from "@upstash/workflow"; const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, // The record's TTL runs from creation and is never extended. Once it passes, the record is gone // and `isCancelled()` returns true, so give long work a longer one than the 5-minute default. defaults: { ttlMs: 60 * 60 * 1000 }, @@ -371,7 +394,7 @@ export const events = createEventLayer({ delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), // `secretKey` defaults to MCP_EVENTS_SECRET_KEY, which encrypts the hosts' signing secrets at // rest. There is no built-in default: generate one with `openssl rand -base64 32`. - principal: (auth) => auth?.extra?.userId as string | undefined, // required, as for tasks + principal: ({ auth }) => auth?.extra?.userId as string | undefined, // required, as for tasks }); export const commentCreated = events.define("comment.created", { @@ -435,7 +458,7 @@ The callback URL decides **where** an event goes. The principal decides **who** host generated for it. ChatGPT sends a unique `connectors.api.openai.com/webhook/mcp-events/` per monitor, so posting there reaches the right user. Your server never needs to know who the host user is. -- **Your server decides who receives what.** `principal(auth)` gives the owner id, which is stored on +- **Your server decides who receives what.** `principal` gives the owner id, which is stored on the subscription and is part of its id. Every `emit` names its `owner` or `owners`, so a subscription with no arguments still only hears about its own user's events. `authorize` adds a per-argument check on each subscribe ("can this user see this document"), and only the owner can @@ -473,7 +496,7 @@ Deliveries only go to the task owner's subscriptions. `allowInsecureCallbacks` for local development only. - **The secret.** `whsec_` plus 24–64 base64 bytes, stored AES-256-GCM encrypted under `secretKey`. A refresh with the same secret skips the challenge; a new one re-verifies. -- **Authorization.** `authorize(args, { principal, auth })` runs on every subscribe and refresh. +- **Authorization.** `authorize(args, { principal, auth, request })` runs on every subscribe and refresh. - **Lifetime.** The host's `ttlMs` is granted up to `defaults.maxTtlMs` (30 days); `refreshBefore` tells it when to subscribe again. - **Host answers.** `410` deletes the subscription, `413` and redirects drop the event, anything else @@ -527,7 +550,7 @@ neither is durable, which is exactly the failure this package is about. | | | | ------------------------- | ----------------------------------------------------------------------------------------------------------- | | `store`, `dispatcher` | Required. | -| `principal` | Required. `(auth) => string \| undefined` — the caller's id; `undefined` refuses the call. | +| `principal` | Required. `({ auth, request }) => string \| undefined`, may be async — the caller's id; `undefined` refuses the call. | | `defaults.ttlMs` | Retention window, `null` for unlimited. Default 5 min. | | `defaults.pollIntervalMs` | Poll interval suggested to the model. Default 2s. | | `toolNames` | Rename `task_status` / `task_cancel`, e.g. to namespace them. | @@ -573,7 +596,7 @@ itself, or with `UPSTASH_DISABLE_TELEMETRY`. | `SettleResult`, `PrincipalResolver` | What `settle` returns, and the type of `principal` | | `Task`, `WireTask`, `TaskStatus`, `TaskError` | The record, and the subset the model sees | | `isTerminal`, `TERMINAL_STATUSES`, `UnknownTaskError` | Status helpers and the store's error type | -| `DEFAULT_TOOL_NAMES`, `CallerAuth` | `{ status: "task_status", cancel: "task_cancel" }`, and what `principal` receives | +| `DEFAULT_TOOL_NAMES`, `Caller`, `CallerAuth` | `{ status: "task_status", cancel: "task_cancel" }`, and what `principal` receives | | `MemoryTaskStore`, `InlineTaskDispatcher` | Non-durable backends for tests | | `@upstash/mcp-toolkit/tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher` | @@ -614,7 +637,7 @@ interface EventDelivery { | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `store`, `delivery` | Required. | | `secretKey` | Encrypts stored signing secrets. Defaults to `MCP_EVENTS_SECRET_KEY`; required, no built-in default. | -| `principal` | Required. `(auth) => string \| undefined` — owns each subscription; `undefined` refuses. | +| `principal` | Required. `({ auth, request }) => string \| undefined`, may be async — owns each subscription; `undefined` refuses. | | `defaults.ttlMs` / `defaults.maxTtlMs` | Granted lifetime when none is asked for (7 days), and the cap (30 days). | | `allowInsecureCallbacks` | Accept `http://` and private hosts. Local development only. | | `timeoutMs` | Per-POST timeout. Default 10s. | diff --git a/packages/mcp-toolkit/src/events/core.test.ts b/packages/mcp-toolkit/src/events/core.test.ts index b7c1b4b..8b55d38 100644 --- a/packages/mcp-toolkit/src/events/core.test.ts +++ b/packages/mcp-toolkit/src/events/core.test.ts @@ -93,7 +93,7 @@ function setup(options: Partial & { respond?: Respond } = {}) store, delivery: new InlineDelivery({ onOutcome: (_job, outcome) => outcomes.push(outcome) }), secretKey: "test-key", - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, fetch: receiver.fetch, ...layerOptions, }); @@ -445,6 +445,16 @@ describe("principal", () => { } }); + it("can identify the caller from the raw request, e.g. a session cookie", async () => { + // No AuthInfo at all: an app that authenticates with its own session reads the request. + const { subscribe, store } = setup({ + principal: async ({ request }) => (request ? "session-user" : undefined), + }); + const sub = await subscribe({ documentId: "d" }, { user: null }); + expect(sub.error).toBeUndefined(); + expect((await store.get(String(sub.result?.id)))?.owner).toBe("session-user"); + }); + it("is required", () => { expect(() => createEventLayer({ @@ -463,7 +473,7 @@ describe("task.finished", () => { const tasks = createTaskLayer({ store: new MemoryTaskStore(), dispatcher: new InlineTaskDispatcher(), - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, onSettle: taskFinished.onSettle, }); tasks.define("slow", { description: "slow", inputSchema: z.object({}) }, async () => ({ diff --git a/packages/mcp-toolkit/src/events/core.ts b/packages/mcp-toolkit/src/events/core.ts index 68345fa..e44a22a 100644 --- a/packages/mcp-toolkit/src/events/core.ts +++ b/packages/mcp-toolkit/src/events/core.ts @@ -6,7 +6,8 @@ import { ProtocolError, type McpServer } from "@modelcontextprotocol/server"; import * as z from "zod"; import { - authOf, + callerOf, + type Caller, requirePrincipal, type CallerAuth, type PrincipalResolver, @@ -36,7 +37,7 @@ const MAX_MATCH_KEYS = 8; /** The challenge echo is tiny; never read more than this from a callback. */ const MAX_CHALLENGE_RESPONSE_BYTES = 4096; -export type { CallerAuth }; +export type { Caller, CallerAuth }; export type EventLayerOptions = { store: SubscriptionStore; @@ -82,7 +83,7 @@ export type EventConfig, - caller: { principal: string; auth?: CallerAuth }, + caller: { principal: string } & Caller, ) => boolean | Promise; /** An extra filter for what exact argument matching cannot express. Runs per subscription. */ match?: (args: z.output, payload: z.output) => boolean; @@ -334,12 +335,12 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { if (problem || !url) throw invalid(problem ?? "callback URL is missing", "invalid_callback_url"); - const auth = authOf(ctx); - const owner = principal(auth); + const caller = callerOf(ctx); + const owner = await principal(caller); if (owner === undefined) throw notAuthenticated(); if ( definition.config.authorize && - !(await definition.config.authorize(args, { principal: owner, auth })) + !(await definition.config.authorize(args, { principal: owner, ...caller })) ) { throw invalid("Not authorized to subscribe with these arguments", "not_authorized"); } @@ -376,7 +377,7 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { } async function unsubscribe(params: z.output, ctx: unknown) { - const owner = principal(authOf(ctx)); + const owner = await principal(callerOf(ctx)); if (owner === undefined) throw notAuthenticated(); const parsed = definitions.get(params.name)?.input.safeParse(params.arguments ?? {}); if (parsed?.success && params.delivery.url) { diff --git a/packages/mcp-toolkit/src/events/index.ts b/packages/mcp-toolkit/src/events/index.ts index ff351e6..59d13ee 100644 --- a/packages/mcp-toolkit/src/events/index.ts +++ b/packages/mcp-toolkit/src/events/index.ts @@ -11,6 +11,7 @@ export { EventPayloadTooLargeError, CALLBACK_ENDPOINT_ERROR, MAX_PAYLOAD_BYTES, + type Caller, type CallerAuth, type EmitOptions, type EmitRecipients, diff --git a/packages/mcp-toolkit/src/shared/auth.ts b/packages/mcp-toolkit/src/shared/auth.ts index 2de5162..14c2849 100644 --- a/packages/mcp-toolkit/src/shared/auth.ts +++ b/packages/mcp-toolkit/src/shared/auth.ts @@ -1,31 +1,48 @@ /** - * The slice of the SDK's `AuthInfo` the toolkit reads. Typed structurally so a principal resolver - * does not need to import the SDK's auth types. + * The SDK's `AuthInfo`, typed structurally so a principal resolver needs no SDK import. It is what + * your route passes to `handler.fetch(request, { authInfo })` after verifying the token: the SDK + * never fills it from headers itself. */ export type CallerAuth = { + token?: string; clientId?: string; scopes?: string[]; + expiresAt?: number; extra?: Record; [key: string]: unknown; }; +/** What `principal` is handed for each call. */ +export type Caller = { + /** The verified `AuthInfo` your route passed to the SDK, if any. Prefer this. */ + auth: CallerAuth | undefined; + /** + * The raw HTTP request, for apps that authenticate with a cookie or session. It is unverified: + * check the session yourself, and never trust a header like `x-user-id` the caller can set. + */ + request: Request | undefined; +}; + /** - * Maps a request's auth to a stable caller id, usually your user id. `undefined` means "not - * authenticated", and the request is refused. + * Maps a call to a stable caller id, usually your user id. `undefined` means "not + * authenticated", and the call is refused. May be async, e.g. to look up a session. */ -export type PrincipalResolver = (auth: CallerAuth | undefined) => string | undefined; +export type PrincipalResolver = ( + caller: Caller, +) => string | undefined | Promise; -/** The `AuthInfo` an SDK request handler context carries, if any. */ -export function authOf(context: unknown): CallerAuth | undefined { - return (context as { http?: { authInfo?: CallerAuth } } | undefined)?.http?.authInfo; +/** The auth and request an SDK request handler context carries. */ +export function callerOf(context: unknown): Caller { + const http = (context as { http?: { authInfo?: CallerAuth; req?: Request } } | undefined)?.http; + return { auth: http?.authInfo, request: http?.req }; } /** Throws at construction when a layer is built without a principal. */ export function requirePrincipal(principal: unknown, layer: string): PrincipalResolver { if (typeof principal !== "function") { throw new Error( - `${layer} needs a \`principal\`: (auth) => your user id. A server with no users of its own ` + - 'passes `principal: () => "local"`.', + `${layer} needs a \`principal\`: ({ auth }) => your user id. A server with no users of its ` + + 'own passes `principal: () => "local"`.', ); } return principal as PrincipalResolver; diff --git a/packages/mcp-toolkit/src/tasks/core.test.ts b/packages/mcp-toolkit/src/tasks/core.test.ts index 5c0abe2..da0f7a2 100644 --- a/packages/mcp-toolkit/src/tasks/core.test.ts +++ b/packages/mcp-toolkit/src/tasks/core.test.ts @@ -40,7 +40,7 @@ type Harness = { type ReportArgs = { topic: string }; /** Every test identifies callers by the user id their auth carries. */ -const principal = (auth: { extra?: Record } | undefined) => +const principal = ({ auth }: { auth?: { extra?: Record } }) => auth?.extra?.userId as string | undefined; /** Builds a server with one task tool backed by `handler`. */ @@ -305,6 +305,21 @@ describe("createTaskLayer over MCP", () => { }); describe("ownership", () => { + it("hands principal the verified auth and the raw request, and accepts an async resolver", async () => { + const seen: { userId: unknown; url: string | undefined }[] = []; + live = await harness(steppedHandler(1, 1), { + principal: async ({ auth, request }) => { + seen.push({ userId: auth?.extra?.userId, url: request?.url }); + await sleep(1); + return auth?.extra?.userId as string | undefined; + }, + }); + const taskId = await live.start("x", "alice"); + expect((await live.store.get(taskId))?.owner).toBe("alice"); + expect(seen[0]).toEqual({ userId: "alice", url: "http://localhost/mcp" }); + await live.dispatcher.drain(); + }); + it("lets the owner read and cancel its task", async () => { live = await harness(steppedHandler(4, 30)); const taskId = await live.start("x", "alice"); diff --git a/packages/mcp-toolkit/src/tasks/core.ts b/packages/mcp-toolkit/src/tasks/core.ts index a3ffdde..7d60b5e 100644 --- a/packages/mcp-toolkit/src/tasks/core.ts +++ b/packages/mcp-toolkit/src/tasks/core.ts @@ -6,7 +6,8 @@ import type { McpServer, StandardSchemaWithJSON } from "@modelcontextprotocol/server"; import * as z from "zod"; import { - authOf, + callerOf as callerFrom, + type Caller, requirePrincipal, type CallerAuth, type PrincipalResolver, @@ -32,7 +33,7 @@ const INTERNAL_ERROR = -32603; /** The names the two shared tools are registered under, unless overridden. */ export const DEFAULT_TOOL_NAMES = { status: "task_status", cancel: "task_cancel" } as const; -export type { CallerAuth }; +export type { Caller, CallerAuth }; export type TaskLayerOptions = { store: TaskStore; @@ -44,7 +45,7 @@ export type TaskLayerOptions = { pollIntervalMs?: number; }; /** - * Who is calling, usually your user id: `(auth) => auth?.extra?.userId`. Required. Every task + * Who is calling, usually your user id: `({ auth }) => auth?.extra?.userId`. Required. Every task * is owned by its caller, and `undefined` refuses the call. `auth.clientId` is the OAuth app * (shared by every ChatGPT user), so it is the wrong key. A server with no users of its own * passes `() => "local"`. @@ -143,7 +144,7 @@ export function createTaskLayer( >(); const wired = new WeakSet(); - const callerOf = (context: unknown) => principal(authOf(context)); + const callerOf = async (context: unknown) => await principal(callerFrom(context)); async function runSettleHook(task: Task): Promise { if (!onSettle) return; @@ -189,7 +190,7 @@ export function createTaskLayer( config: TaskToolConfig, ): void { const callback = async (args: unknown, context: unknown): Promise> => { - const owner = callerOf(context); + const owner = await callerOf(context); if (owner === undefined) return notAuthenticatedResult(); const key = config.idempotencyKey?.(args); // A keyed call gets a deterministic id scoped to its caller, so a retry finds the first task. @@ -267,7 +268,7 @@ export function createTaskLayer( annotations: { readOnlyHint: true, idempotentHint: true }, }, (async ({ taskId }: { taskId: string }, context: unknown) => { - const caller = callerOf(context); + const caller = await callerOf(context); if (caller === undefined) return notAuthenticatedResult(); const task = await owned(taskId, caller); return task ? statusResult(task) : unknownTaskResult(taskId); @@ -285,7 +286,7 @@ export function createTaskLayer( annotations: { destructiveHint: true, idempotentHint: true }, }, (async ({ taskId }: { taskId: string }, context: unknown) => { - const caller = callerOf(context); + const caller = await callerOf(context); if (caller === undefined) return notAuthenticatedResult(); if (!(await owned(taskId, caller))) return unknownTaskResult(taskId); const task = await cancelTask(taskId); diff --git a/packages/mcp-toolkit/src/tasks/index.ts b/packages/mcp-toolkit/src/tasks/index.ts index 7e60dce..d22dcec 100644 --- a/packages/mcp-toolkit/src/tasks/index.ts +++ b/packages/mcp-toolkit/src/tasks/index.ts @@ -6,6 +6,7 @@ export { createTaskLayer, toWire, DEFAULT_TOOL_NAMES, + type Caller, type CallerAuth, type TaskDefinition, type TaskHandler, From c7543af1e12f352999dcc38647a02fc40007b12c Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 12:18:47 +0000 Subject: [PATCH 22/33] DX-3022: mcp-toolkit: authorize at subscribe and delivery, optional `to`, typed args - authorize is required on every event and runs again before each delivery (phase "deliver"), so revoked access stops events; refused deliveries are dropped - emit no longer needs recipients: matching subscriptions that authorize allows get it; `to` optionally narrows it, and `personal: true` events require `to` (type and runtime). task.finished is personal, emitted to the task's owner - principal may return { id, context }: non-secret context (<= 4 KB) is stored on the subscription and handed back to authorize at delivery. AuthInfo is never stored - emit's `args` is optional only when the payload carries every input field; otherwise the type requires it, and a missing required input value throws - the stored field is `subscriber`; the Redis index is per (event, argsKey) again - tests: readers subscribe, a non-reader is refused, one emit reaches each reader; revocation, context at delivery, personal events, typed args (@ts-expect-error) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK --- CLAUDE.md | 26 ++- .../mcp-toolkit-demo/app/lib/deploy-watch.ts | 10 +- packages/mcp-toolkit/README.md | 94 +++++--- .../mcp-toolkit/src/events/backends/memory.ts | 12 +- .../src/events/backends/qstash.test.ts | 44 ++-- .../mcp-toolkit/src/events/backends/qstash.ts | 32 +-- packages/mcp-toolkit/src/events/core.test.ts | 219 ++++++++++++++---- packages/mcp-toolkit/src/events/core.ts | 214 ++++++++++++----- packages/mcp-toolkit/src/events/index.ts | 4 +- packages/mcp-toolkit/src/events/tasks.ts | 6 +- packages/mcp-toolkit/src/events/types.ts | 26 +-- packages/mcp-toolkit/src/shared/auth.ts | 26 ++- packages/mcp-toolkit/src/tasks/core.ts | 4 +- packages/mcp-toolkit/src/tasks/index.ts | 2 +- 14 files changed, 494 insertions(+), 225 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f5b6596..cc6efb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -732,16 +732,16 @@ implements TanStack AI's own backend contracts (see its section below) — keep that returns `[changed, ...HGETALL]`. `settle` returns `{ task, settled }` (null only when the task is missing): `settled` drives `onSettle` and dispatcher cancel, and the record comes back even when nothing changed, so `cancelTask` is one write. `SubscriptionStore.delete` takes - `{ id, event, argsKey, owner }` so the Redis store drops record + index in one pipeline. + `{ id, event, argsKey }` so the Redis store drops record + index in one pipeline. - **Dispatch dedupe is per record, not per task id:** `dispatchKey(task)` = `${taskId}-${createdAt ms}`. QStash remembers a `deduplicationId` for 10 minutes and Workflow refuses a reused `workflowRunId`, while a keyed task id comes back once its 5-minute record expires. A failed `dispatch` settles the fresh record `failed` ("Could not be queued") and rethrows. The `task.finished` event id is `evt_task_${dispatchKey(task)}` for the same reason: QStash and hosts dedupe on it. -- **`RedisSubscriptionStore.find` batches:** one `ZRANGE` per owner × argument subset (up to 256 - per owner), split into pipelines and `MGET`s of at most 1,000 commands, so `emit({ owners })` - with a large team never sends one giant request. +- **`RedisSubscriptionStore.find` batches:** one `ZRANGE` per argument subset (up to 256), then + `MGET`s of the matches, each split into requests of at most 1,000 commands, so a popular event + never sends one giant request. - **No secret defaults:** `MCP_EVENTS_SECRET_KEY` has no fallback, demo included; the event layer resolves it on first use (not at construction, so builds without env still work) and a missing key throws rather than dropping deliveries. @@ -802,11 +802,19 @@ implements TanStack AI's own backend contracts (see its section below) — keep - **Events facts.** Wire format follows ChatGPT's MCP Events (webhook only): `events/list|subscribe| unsubscribe` registered via `server.server.setRequestHandler(method, { params }, handler)` and `registerCapabilities({ events: {} } as never)` (the SDK has no events types). Subscription id = - `sub_` + sha256(canonical `[owner, url, event, args]`). Matching = exact canonical args: emit - enumerates every subset of its args (cap 8 keys) and the store looks those keys up. **Every `emit` - names `owner` or `owners`** (DX-3022 review S4) and the Redis index is per - `(event, owner, argsKey)`, so a `{}` subscription gets everything *of its own user* and nothing of - anyone else's. Secrets are AES-256-GCM sealed under `secretKey` (`MCP_EVENTS_SECRET_KEY`); a + `sub_` + sha256(canonical `[subscriber, url, event, args]`). Matching = exact canonical args: emit + enumerates every subset of its args (cap 8 keys) and the store looks those keys up (index per + `(event, argsKey)`). **Access model (replaced the review's "every emit names owners"):** the + filter says *what*, a **required** `authorize` says *who may* — it runs at subscribe/refresh + (`phase: "subscribe"`, with `auth`/`request`) and again in `send()` before every delivery + (`phase: "deliver"`, only the stored `principal` + `context`; the token is never stored), so + revoked access stops events; refused deliveries are `dropped`, a throwing `authorize` is `retry`. + `to` optionally narrows an emit; `personal: true` events make `to` required in the type and at + runtime (`task.finished` is personal, `to: task.owner`). `principal` may return + `{ id, context }`; context is non-secret JSON (≤ 4 KB) stored as `subscription.context`. The + stored field is `subscriber` (not "owner", which read like document ownership). `emit`'s `args` + is optional only when every input key is a payload key; otherwise the type requires it, and a + runtime check throws when a required input field has no value. Secrets are AES-256-GCM sealed under `secretKey` (`MCP_EVENTS_SECRET_KEY`); a refresh with the same secret skips the challenge. A failed challenge is always the same `ProtocolError(-32015, "Callback URL failed verification")` with no reason or status (no network probing); the detail is `console.warn`ed. The challenge answer is read capped at 4 KB, delivery diff --git a/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts index c9fcfbc..2229e93 100644 --- a/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts +++ b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts @@ -64,18 +64,18 @@ export const deployFinished = events.define("deploy.finished", { environment: z.enum(["production", "staging"]).optional().describe("Only this environment"), }), payload: Deploy, + // Everyone may watch every deploy in the demo. A real server checks whether this user can see + // the service, and the check runs again before each delivery, so revoked access stops events. + authorize: () => true, }); -/** - * Records a deploy and fires `deploy.finished` to the matching subscriptions of the users who may - * see it. Here that is the one demo user; a real server would pass the team's members as `owners`. - */ +/** Records a deploy and fires `deploy.finished` to every matching subscription `authorize` allows. */ export async function reportDeploy(deploy: Deploy) { const redis = Redis.fromEnv(); await redis.lpush(RECENT_KEY, JSON.stringify(deploy)); await redis.ltrim(RECENT_KEY, 0, 19); // The deploy id doubles as the event id, so reporting the same deploy twice delivers once. - return deployFinished.emit(deploy, { owner: DEMO_USER, eventId: `deploy_${deploy.id}` }); + return deployFinished.emit(deploy, { eventId: `deploy_${deploy.id}` }); } export function createServer(): McpServer { diff --git a/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md index aa19cc2..4468a07 100644 --- a/packages/mcp-toolkit/README.md +++ b/packages/mcp-toolkit/README.md @@ -401,6 +401,7 @@ export const commentCreated = events.define("comment.created", { description: "A new review comment was added to a document.", input: z.object({ documentId: z.string() }), payload: z.object({ documentId: z.string(), commentId: z.string(), text: z.string() }), + // Required. Runs when a host subscribes or refreshes, and again before every delivery. authorize: (args, { principal }) => canRead(principal, args.documentId), }); ``` @@ -422,15 +423,13 @@ export const POST = events.createDeliveryHandler(); Then emit from wherever the change happens — a route, a webhook from your own app, a job: ```ts -await commentCreated.emit( - { documentId: "doc_123", commentId: "c_9", text: "Ship it?" }, - { owners: await membersOf("doc_123") }, // who may see it: required -); +await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); ``` -`emit` is typed by the `payload` schema and validates against it. It always names its recipients, -`owner` for one user or `owners` for several, and reaches only their subscriptions whose arguments -match. Each match is handed to QStash. The delivery route signs the +`emit` is typed by the `payload` schema and validates against it. It finds every subscription whose +arguments match (here, everyone watching `doc_123`) and hands each one to QStash. Before each +delivery, `authorize` runs again for that subscriber, so someone who lost access to the document +stops getting its comments. The delivery route signs the envelope with that subscriber's secret, POSTs it, and answers 500 when the callback failed so QStash retries with backoff. Each attempt is signed fresh, and the event id stays the same, so the host can drop duplicates. @@ -439,12 +438,28 @@ host can drop duplicates. A subscription matches when every argument it gave equals the value emitted. Emitting `{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of -`{ repo: "a", branch: "main" }`, and of `{}`. By default the values come from the payload fields -named in the input schema; pass `args` to set them explicitly, and `eventId` to make a repeated emit -deduplicate: +`{ repo: "a", branch: "main" }`, and of `{}`. + +The values come from the payload fields named in the input schema, so when the payload carries +every input field, `emit(payload)` needs nothing else. When it doesn't, the type of `emit` makes +`args` required, and a runtime check refuses an emit that has no value for a required input field, +so a filter can never silently match nothing: ```ts -await commentCreated.emit(payload, { owner: userId, args: { documentId }, eventId: comment.id }); +const replyPosted = events.define("reply.posted", { + description: "A reply was posted in a thread.", + input: z.object({ threadId: z.string() }), + payload: z.object({ text: z.string() }), // no threadId here + authorize: (args, { principal }) => canReadThread(principal, args.threadId), +}); + +await replyPosted.emit({ text: "Agreed" }, { args: { threadId } }); // `args` is required +``` + +Pass `eventId` to make a repeated emit deduplicate, and `to` to narrow it to specific users: + +```ts +await commentCreated.emit(payload, { to: ["alice", "carol"], eventId: comment.id }); ``` For conditions exact matching cannot express, add `match: (args, payload) => boolean` to the @@ -452,20 +467,39 @@ definition. ### Users and subscriptions -The callback URL decides **where** an event goes. The principal decides **who** may receive it. +Three things decide who gets an event: the callback URL says **where**, the subscription's +arguments say **what**, and `authorize` says **who may**. - **The host routes to its user.** Each subscription carries a callback URL and signing secret the host generated for it. ChatGPT sends a unique `connectors.api.openai.com/webhook/mcp-events/` per monitor, so posting there reaches the right user. Your server never needs to know who the host user is. -- **Your server decides who receives what.** `principal` gives the owner id, which is stored on - the subscription and is part of its id. Every `emit` names its `owner` or `owners`, so a - subscription with no arguments still only hears about its own user's events. `authorize` adds a - per-argument check on each subscribe ("can this user see this document"), and only the owner can - unsubscribe. -- **No anonymous subscriptions.** `principal` is required, and when it returns `undefined` the - subscribe (or unsubscribe) is refused with reason `not_authenticated`, before any challenge is - sent. +- **`authorize` is required.** It runs on every subscribe and refresh, with `{ principal, auth, + request }`, and again before every delivery with just the stored `principal` and `context`: the + token is never stored, so there is no `auth` behind a delivery. An event any authenticated caller + may hear says so with `authorize: () => true`. +- **`principal` can carry context.** Return `{ id, context }` instead of a string to store small, + non-secret data (an org id, a role) on the subscription. `authorize` gets it back as + `caller.context` at delivery time. It is a snapshot from subscribe time, so revocation checks + should query your own data with `principal`. +- **Personal events use `to`.** Some events belong to one user, like "your export finished". Mark + them `personal: true`, and every `emit` must say who with `to` (the type requires it, and so does + a runtime check). A subscription with no arguments then only hears about its own user's events. + `to` also works on ordinary events, to narrow one emit. +- **No anonymous subscriptions.** When `principal` returns `undefined`, subscribe and unsubscribe + are refused with reason `not_authenticated`, before any challenge is sent. Only the subscriber + can unsubscribe. + +```ts +const exportFinished = events.define("export.finished", { + description: "Your export finished.", + payload: z.object({ exportId: z.string(), url: z.string() }), + personal: true, + authorize: () => true, // `to` already limits it to the export's owner +}); + +await exportFinished.emit({ exportId, url }, { to: userId }); +``` ### `task.finished`: tasks that push instead of being polled @@ -482,7 +516,7 @@ const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFini A host subscribes with no arguments to hear about every task its user starts, or with `taskId` for one. The payload carries the status and, when it fits in the 256 KiB envelope, the result. -Deliveries only go to the task owner's subscriptions. +It is a personal event, emitted `to` the task's owner, so deliveries only go to their subscriptions. ### What the layer checks for you @@ -496,7 +530,8 @@ Deliveries only go to the task owner's subscriptions. `allowInsecureCallbacks` for local development only. - **The secret.** `whsec_` plus 24–64 base64 bytes, stored AES-256-GCM encrypted under `secretKey`. A refresh with the same secret skips the challenge; a new one re-verifies. -- **Authorization.** `authorize(args, { principal, auth, request })` runs on every subscribe and refresh. +- **Authorization.** `authorize` runs on every subscribe and refresh, and again before every + delivery. A delivery it refuses is dropped, not retried. - **Lifetime.** The host's `ttlMs` is granted up to `defaults.maxTtlMs` (30 days); `refreshBefore` tells it when to subscribe again. - **Host answers.** `410` deletes the subscription, `413` and redirects drop the event, anything else @@ -610,9 +645,9 @@ interface SubscriptionStore { put(subscription: Subscription): Promise; get(id: string): Promise; /** Gets the index coordinates along with the id, so it needs no read first. */ - delete(subscription: Pick): Promise; - /** Every live subscription to `event`, owned by one of `owners`, with one of `argsKeys`. */ - find(event: string, owners: string[], argsKeys: string[]): Promise; + delete(subscription: Pick): Promise; + /** Every live subscription to `event` whose canonical arguments are one of `argsKeys`. */ + find(event: string, argsKeys: string[]): Promise; } interface EventDelivery { @@ -623,7 +658,8 @@ interface EventDelivery { ``` `RedisSubscriptionStore` keeps one expiring key per subscription and a sorted set per -`(event, owner, arguments)` scored by expiry, so an emit reads only the named owners' entries. +`(event, arguments)` scored by expiry, so an emit reads only what it can match, in batches of at +most 1,000 commands. `MemorySubscriptionStore` + `InlineDelivery` (sends in-process, no retries) ship for tests. @@ -637,12 +673,13 @@ interface EventDelivery { | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `store`, `delivery` | Required. | | `secretKey` | Encrypts stored signing secrets. Defaults to `MCP_EVENTS_SECRET_KEY`; required, no built-in default. | -| `principal` | Required. `({ auth, request }) => string \| undefined`, may be async — owns each subscription; `undefined` refuses. | +| `principal` | Required. `({ auth, request }) => id \| { id, context } \| undefined`, may be async — the subscriber; `undefined` refuses. | | `defaults.ttlMs` / `defaults.maxTtlMs` | Granted lifetime when none is asked for (7 days), and the cap (30 days). | | `allowInsecureCallbacks` | Accept `http://` and private hosts. Local development only. | | `timeoutMs` | Per-POST timeout. Default 10s. | -**`define` config** — `description`, `payload`, plus optional `title`, `input`, `authorize`, `match`. +**`define` config** — `description`, `payload` and `authorize` (required), plus optional `title`, +`input`, `personal` and `match`. **`RedisSubscriptionStore`** — `redis`, `prefix` (default `mcp-events:`), `enableTelemetry`. @@ -662,6 +699,7 @@ interface EventDelivery { | `signWebhook`, `verifyWebhook` | Standard Webhooks signing, and verification for writing a receiver | | `callbackUrlProblem`, `decodeSecret`, `SecretBox` | The callback, secret and encryption checks the layer uses | | `EventPayloadTooLargeError`, `CALLBACK_ENDPOINT_ERROR`, `MAX_PAYLOAD_BYTES` | Limits and errors | +| `AuthorizeCaller`, `Recipients`, `Principal`, `EmitOptions` | What `authorize` is told, what `to` takes, what `principal` returns | | `MemorySubscriptionStore`, `InlineDelivery` | Non-durable backends for tests | | `RedisSubscriptionStore`, `QStashDelivery` | The Upstash backends (need `@upstash/redis` and `@upstash/qstash`) | diff --git a/packages/mcp-toolkit/src/events/backends/memory.ts b/packages/mcp-toolkit/src/events/backends/memory.ts index f7a9966..72fbe6e 100644 --- a/packages/mcp-toolkit/src/events/backends/memory.ts +++ b/packages/mcp-toolkit/src/events/backends/memory.ts @@ -29,21 +29,13 @@ export class MemorySubscriptionStore implements SubscriptionStore { this.subscriptions.delete(id); } - async find( - event: string, - owners: readonly string[], - argsKeys: readonly string[], - ): Promise { - const ownerSet = new Set(owners); + async find(event: string, argsKeys: readonly string[]): Promise { const keys = new Set(argsKeys); return [...this.subscriptions.keys()] .map((id) => this.live(id)) .filter( (sub): sub is Subscription => - sub !== undefined && - sub.event === event && - ownerSet.has(sub.owner) && - keys.has(sub.argsKey), + sub !== undefined && sub.event === event && keys.has(sub.argsKey), ) .map((sub) => ({ ...sub })); } diff --git a/packages/mcp-toolkit/src/events/backends/qstash.test.ts b/packages/mcp-toolkit/src/events/backends/qstash.test.ts index 04a2e41..67cdc1c 100644 --- a/packages/mcp-toolkit/src/events/backends/qstash.test.ts +++ b/packages/mcp-toolkit/src/events/backends/qstash.test.ts @@ -13,7 +13,8 @@ const makeSub = (overrides: Partial = {}): Subscription => { argsKey: canonicalJson(args), url: "https://receiver.example.com/cb", encryptedSecret: "v1.aaa.bbb", - owner: "123", + subscriber: "123", + context: { org: "acme" }, createdAt: new Date().toISOString(), expiresAt: Date.now() + 60_000, ...overrides, @@ -29,7 +30,7 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { await cleanupKeys(redis, prefix); }); - it("round-trips a subscription, keeping a numeric-looking owner a string", async () => { + it("round-trips a subscription, keeping a numeric-looking subscriber a string", async () => { const sub = makeSub(); await store.put(sub); expect(await store.get(sub.id)).toEqual(sub); @@ -51,30 +52,23 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { const other = makeSub({ event: "find", args: { repo: "b" } }); for (const sub of [all, one, other]) await store.put(sub); - const found = await store.find("find", ["123"], ["{}", canonicalJson({ repo: "a" })]); + const found = await store.find("find", ["{}", canonicalJson({ repo: "a" })]); expect(found.map((s) => s.id).sort()).toEqual([all.id, one.id].sort()); - expect(await store.find("find", ["123"], [])).toEqual([]); - expect(await store.find("other-event", ["123"], ["{}"])).toEqual([]); + expect(await store.find("find", [])).toEqual([]); + expect(await store.find("other-event", ["{}"])).toEqual([]); }); - it("only finds the subscriptions of the owners asked for", async () => { - const alice = makeSub({ event: "owned", args: {}, owner: "alice" }); - const bob = makeSub({ event: "owned", args: {}, owner: "bob" }); - for (const sub of [alice, bob]) await store.put(sub); - expect((await store.find("owned", ["alice"], ["{}"])).map((s) => s.id)).toEqual([alice.id]); - expect(await store.find("owned", ["mallory"], ["{}"])).toEqual([]); - const both = await store.find("owned", ["alice", "bob"], ["{}"]); - expect(both.map((s) => s.id).sort()).toEqual([alice.id, bob.id].sort()); - }); - - it("splits a lookup across many owners into bounded pipelines", async () => { - // 1,500 owners is 1,500 ZRANGEs: more than one pipeline's worth. - const owners = Array.from({ length: 1500 }, (_, i) => `member-${i}`); - const picked = [owners[3]!, owners[1100]!, owners[1499]!]; - const subs = picked.map((owner) => makeSub({ event: "team", args: {}, owner })); - for (const sub of subs) await store.put(sub); - const found = await store.find("team", owners, ["{}"]); - expect(found.map((s) => s.id).sort()).toEqual(subs.map((s) => s.id).sort()); + it("reads a large match in bounded batches", async () => { + // 1,100 matching subscriptions is more than one MGET's worth. + const subs = Array.from({ length: 1100 }, (_, i) => + makeSub({ event: "popular", args: {}, subscriber: `member-${i}` }), + ); + for (let i = 0; i < subs.length; i += 100) { + await Promise.all(subs.slice(i, i + 100).map((sub) => store.put(sub))); + } + const found = await store.find("popular", ["{}"]); + expect(found).toHaveLength(1100); + expect(new Set(found.map((s) => s.id))).toEqual(new Set(subs.map((s) => s.id))); }); it("refreshing replaces the record and extends the index", async () => { @@ -82,7 +76,7 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { await store.put(sub); await store.put({ ...sub, expiresAt: Date.now() + 50_000 }); expect((await store.get(sub.id))?.expiresAt).toBeGreaterThan(Date.now() + 40_000); - expect(await store.find("refresh", [sub.owner], [sub.argsKey])).toHaveLength(1); + expect(await store.find("refresh", [sub.argsKey])).toHaveLength(1); expect(await redis.pttl(await store.indexKey(sub))).toBeGreaterThan(40_000); }); @@ -91,7 +85,7 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { await store.put(sub); await store.delete(sub); expect(await store.get(sub.id)).toBeNull(); - expect(await store.find("delete", [sub.owner], [sub.argsKey])).toEqual([]); + expect(await store.find("delete", [sub.argsKey])).toEqual([]); expect(await redis.zcard(await store.indexKey(sub))).toBe(0); await store.delete(sub); // idempotent }); diff --git a/packages/mcp-toolkit/src/events/backends/qstash.ts b/packages/mcp-toolkit/src/events/backends/qstash.ts index 6ace3ad..a765ef9 100644 --- a/packages/mcp-toolkit/src/events/backends/qstash.ts +++ b/packages/mcp-toolkit/src/events/backends/qstash.ts @@ -50,8 +50,8 @@ return 1 `; /** - * One key per subscription, expiring with it, plus a sorted set per `(event, owner, argsKey)` - * scored by expiry. An emit reads only the index entries of the owners it names. + * One key per subscription, expiring with it, plus a sorted set per `(event, argsKey)` scored by + * expiry. An emit reads only the index entries it can match. */ export class RedisSubscriptionStore implements SubscriptionStore { private readonly prefix: string; @@ -88,19 +88,9 @@ export class RedisSubscriptionStore implements SubscriptionStore { await pipeline.exec(); } - async find( - event: string, - owners: readonly string[], - argsKeys: readonly string[], - ): Promise { - if (owners.length === 0 || argsKeys.length === 0) return []; - const keys = await Promise.all( - owners.flatMap((owner) => - argsKeys.map((argsKey) => this.indexKey({ event, owner, argsKey })), - ), - ); - // One ZRANGE per owner and argument subset (up to 256 per owner), so a large `owners` list is - // split into bounded pipelines rather than sent as one huge request. + async find(event: string, argsKeys: readonly string[]): Promise { + if (argsKeys.length === 0) return []; + const keys = await Promise.all(argsKeys.map((argsKey) => this.indexKey({ event, argsKey }))); const now = Date.now(); const ids = new Set(); for (const batch of chunks(keys, MAX_BATCH)) { @@ -108,6 +98,7 @@ export class RedisSubscriptionStore implements SubscriptionStore { for (const key of batch) pipeline.zrange(key, now, "+inf", { byScore: true }); for (const id of (await pipeline.exec()).flat()) ids.add(String(id)); } + // A popular event can match many subscriptions, so the reads are batched too. const found: Subscription[] = []; for (const batch of chunks([...ids], MAX_BATCH)) { const records = await this.redis().mget(...batch.map((id) => this.subKey(id))); @@ -124,14 +115,9 @@ export class RedisSubscriptionStore implements SubscriptionStore { return `${this.prefix}sub:${id}`; } - /** The index key for one `(event, owner, argsKey)`. Hashed, because arguments can be long. */ - async indexKey({ - event, - owner, - argsKey, - }: Pick): Promise { - const digest = await sha256Hex(JSON.stringify([owner, argsKey])); - return `${this.prefix}idx:${event}:${digest.slice(0, 24)}`; + /** The index key for one `(event, argsKey)`. Hashed, because arguments can be long. */ + async indexKey({ event, argsKey }: Pick): Promise { + return `${this.prefix}idx:${event}:${(await sha256Hex(argsKey)).slice(0, 24)}`; } } diff --git a/packages/mcp-toolkit/src/events/core.test.ts b/packages/mcp-toolkit/src/events/core.test.ts index 8b55d38..d994093 100644 --- a/packages/mcp-toolkit/src/events/core.test.ts +++ b/packages/mcp-toolkit/src/events/core.test.ts @@ -89,6 +89,10 @@ function setup(options: Partial & { respond?: Respond } = {}) const receiver = host(secrets, respond); const outcomes: SendOutcome[] = []; const store = new MemorySubscriptionStore(); + // Who may read which document. Documents not listed are readable by everyone, except secret-doc. + const acl = new Map>(); + const canRead = (user: string, documentId: string) => + documentId !== "secret-doc" && (acl.get(documentId)?.has(user) ?? true); const events = createEventLayer({ store, delivery: new InlineDelivery({ onOutcome: (_job, outcome) => outcomes.push(outcome) }), @@ -102,7 +106,7 @@ function setup(options: Partial & { respond?: Respond } = {}) description: "A new comment on a document.", input: z.object({ documentId: z.string(), author: z.string().optional() }), payload: z.object({ documentId: z.string(), author: z.string(), text: z.string() }), - authorize: (args) => args.documentId !== "secret-doc", + authorize: (args, { principal }) => canRead(principal, args.documentId), }); const handler = createMcpHandler(() => { @@ -147,7 +151,7 @@ function setup(options: Partial & { respond?: Respond } = {}) return { ...response, url, secret }; }; - return { events, store, rpc, subscribe, receiver, outcomes, commentCreated }; + return { events, store, rpc, subscribe, receiver, outcomes, commentCreated, acl }; } describe("events/list and capability", () => { @@ -212,9 +216,7 @@ describe("events/subscribe", () => { const { subscribe, store } = setup({ respond: () => Response.json({ challenge: "nope" }) }); const response = await subscribe({ documentId: "doc_1" }); expect(response.error?.code).toBe(-32015); - expect(await store.find("comment.created", ["alice"], ['{"documentId":"doc_1"}'])).toHaveLength( - 0, - ); + expect(await store.find("comment.created", ['{"documentId":"doc_1"}'])).toHaveLength(0); }); it("gives the same error whatever went wrong, so the network behind it cannot be probed", async () => { @@ -290,10 +292,7 @@ describe("emit", () => { await subscribe({ documentId: "doc_1", author: "bob" }); await subscribe({ documentId: "doc_2" }); - const result = await commentCreated.emit( - { documentId: "doc_1", author: "alice", text: "hi" }, - { owner: "alice" }, - ); + const result = await commentCreated.emit({ documentId: "doc_1", author: "alice", text: "hi" }); expect(result.matched).toBe(2); const deliveries = receiver.deliveries(); expect(deliveries.map((d) => d.url).sort()).toEqual([all.url, byAlice.url].sort()); @@ -309,55 +308,38 @@ describe("emit", () => { } }); - it("matches on explicit args and filters by owner", async () => { + it("matches on explicit args and narrows to the users in `to`", async () => { const { subscribe, commentCreated, receiver } = setup(); await subscribe({ documentId: "doc_1" }, { user: "alice" }); const bob = await subscribe({ documentId: "doc_1" }, { user: "bob" }); const result = await commentCreated.emit( { documentId: "ignored", author: "x", text: "y" }, - { args: { documentId: "doc_1" }, owner: "bob" }, + { args: { documentId: "doc_1" }, to: "bob" }, ); expect(result.matched).toBe(1); expect(receiver.deliveries()[0]?.url).toBe(bob.url); }); - it("only reaches the owners it names", async () => { + it("reaches every matching subscriber without `to`, and only the named ones with it", async () => { const { subscribe, commentCreated, receiver } = setup(); const alice = await subscribe({ documentId: "doc_1" }, { user: "alice" }); const bob = await subscribe({ documentId: "doc_1" }, { user: "bob" }); const payload = { documentId: "doc_1", author: "x", text: "y" }; - - expect((await commentCreated.emit(payload, { owner: "alice" })).matched).toBe(1); - expect(receiver.deliveries().map((d) => d.url)).toEqual([alice.url]); - expect((await commentCreated.emit(payload, { owner: "mallory" })).matched).toBe(0); - expect((await commentCreated.emit(payload, { owners: ["alice", "bob"] })).matched).toBe(2); - expect( - receiver - .deliveries() - .map((d) => d.url) - .slice(1) - .sort(), - ).toEqual([alice.url, bob.url].sort()); - expect((await commentCreated.emit(payload, { owners: [] })).matched).toBe(0); - }); - - it("refuses to emit without naming who receives it", async () => { - const { commentCreated } = setup(); - await expect( - commentCreated.emit({ documentId: "d", author: "a", text: "x" }, {} as never), - ).rejects.toThrow(/owner/); + const urls = () => receiver.deliveries().map((d) => d.url); + + expect((await commentCreated.emit(payload)).matched).toBe(2); + expect(urls().sort()).toEqual([alice.url, bob.url].sort()); + expect((await commentCreated.emit(payload, { to: "alice" })).matched).toBe(1); + expect(urls().at(-1)).toBe(alice.url); + expect((await commentCreated.emit(payload, { to: ["mallory"] })).matched).toBe(0); + expect((await commentCreated.emit(payload, { to: [] })).matched).toBe(0); }); it("rejects an invalid payload and an oversized one", async () => { const { commentCreated } = setup(); + await expect(commentCreated.emit({ documentId: "d" } as never)).rejects.toThrow(); await expect( - commentCreated.emit({ documentId: "d" } as never, { owner: "alice" }), - ).rejects.toThrow(); - await expect( - commentCreated.emit( - { documentId: "d", author: "a", text: "x".repeat(300 * 1024) }, - { owner: "alice" }, - ), + commentCreated.emit({ documentId: "d", author: "a", text: "x".repeat(300 * 1024) }), ).rejects.toThrow(/limit/); }); @@ -367,18 +349,18 @@ describe("emit", () => { respond: (r) => (r.body.type === "verification" ? undefined : new Response(null, { status })), }); const sub = await subscribe({ documentId: "d" }); - await commentCreated.emit({ documentId: "d", author: "a", text: "1" }, { owner: "alice" }); + await commentCreated.emit({ documentId: "d", author: "a", text: "1" }); expect(outcomes).toEqual(["gone"]); expect(await store.get(String(sub.result?.id))).toBeNull(); status = 413; await subscribe({ documentId: "d" }); - await commentCreated.emit({ documentId: "d", author: "a", text: "2" }, { owner: "alice" }); + await commentCreated.emit({ documentId: "d", author: "a", text: "2" }); status = 503; - await commentCreated.emit({ documentId: "d", author: "a", text: "3" }, { owner: "alice" }); + await commentCreated.emit({ documentId: "d", author: "a", text: "3" }); // A redirect is never followed, so retrying it cannot help either. status = 307; - await commentCreated.emit({ documentId: "d", author: "a", text: "4" }, { owner: "alice" }); + await commentCreated.emit({ documentId: "d", author: "a", text: "4" }); expect(outcomes).toEqual(["gone", "dropped", "retry", "dropped"]); }); @@ -395,10 +377,7 @@ describe("emit", () => { "alice", ); expect(response.result).toBeDefined(); - const after = await commentCreated.emit( - { documentId: "d", author: "a", text: "x" }, - { owner: "alice" }, - ); + const after = await commentCreated.emit({ documentId: "d", author: "a", text: "x" }); expect(after.matched).toBe(0); }); @@ -411,6 +390,150 @@ describe("emit", () => { }); }); +describe("access across users", () => { + /** alice and carol may read doc_shared; mallory may not. */ + const shared = () => { + const env = setup(); + env.acl.set("doc_shared", new Set(["alice", "carol"])); + return env; + }; + const comment = { documentId: "doc_shared", author: "dave", text: "Ship it?" }; + + it("lets readers subscribe, refuses everyone else, and delivers to each reader", async () => { + const { subscribe, commentCreated, receiver, store } = shared(); + + const alice = await subscribe({ documentId: "doc_shared" }, { user: "alice" }); + expect(alice.error).toBeUndefined(); + + // mallory can't read the document, so the subscribe is refused before any challenge. + const challengesBefore = receiver.received.length; + const mallory = await subscribe({ documentId: "doc_shared" }, { user: "mallory" }); + expect(mallory.error?.data?.reason).toBe("not_authorized"); + expect(receiver.received).toHaveLength(challengesBefore); + expect(await store.find("comment.created", ['{"documentId":"doc_shared"}'])).toHaveLength(1); + + const carol = await subscribe({ documentId: "doc_shared" }, { user: "carol" }); + expect(carol.error).toBeUndefined(); + + // One emit, no recipients named: both readers hear it, each on their own signed callback. + const result = await commentCreated.emit(comment); + expect(result.matched).toBe(2); + const deliveries = receiver.deliveries(); + expect(deliveries.map((d) => d.url).sort()).toEqual([alice.url, carol.url].sort()); + for (const delivery of deliveries) { + expect(delivery.valid).toBe(true); + expect(delivery.body).toMatchObject({ eventId: result.eventId, data: comment }); + } + expect(deliveries.some((d) => d.url === mallory.url)).toBe(false); + }); + + it("stops delivering to a subscriber whose access was revoked after subscribing", async () => { + const { subscribe, commentCreated, receiver, outcomes, acl } = shared(); + const alice = await subscribe({ documentId: "doc_shared" }, { user: "alice" }); + await subscribe({ documentId: "doc_shared" }, { user: "carol" }); + + acl.set("doc_shared", new Set(["alice"])); // carol loses access + await commentCreated.emit(comment); + + expect(receiver.deliveries().map((d) => d.url)).toEqual([alice.url]); + expect(outcomes.sort()).toEqual(["delivered", "dropped"]); + }); + + it("hands authorize the principal's context at subscribe and again at delivery", async () => { + const seen: { phase: string; principal: string; org: unknown; hasAuth: boolean }[] = []; + const { events, subscribe, receiver } = setup({ + principal: ({ auth }) => { + const userId = auth?.extra?.userId as string | undefined; + return userId && { id: userId, context: { org: `org-of-${userId}` } }; + }, + }); + const memo = events.define("memo.posted", { + description: "A memo was posted to an org.", + input: z.object({ org: z.string() }), + payload: z.object({ org: z.string(), text: z.string() }), + authorize: ({ org }, caller) => { + seen.push({ + phase: caller.phase, + principal: caller.principal, + org: caller.context.org, + hasAuth: caller.auth !== undefined, + }); + return caller.context.org === org; + }, + }); + + const alice = await subscribe({ org: "org-of-alice" }, { user: "alice", name: "memo.posted" }); + const bob = await subscribe({ org: "org-of-alice" }, { user: "bob", name: "memo.posted" }); + expect(alice.error).toBeUndefined(); + expect(bob.error?.data?.reason).toBe("not_authorized"); + + await memo.emit({ org: "org-of-alice", text: "Offsite on Friday" }); + expect(receiver.deliveries().map((d) => d.url)).toEqual([alice.url]); + // The token is never stored, so only the subscribe-time check sees `auth`. + expect(seen).toEqual([ + { phase: "subscribe", principal: "alice", org: "org-of-alice", hasAuth: true }, + { phase: "subscribe", principal: "bob", org: "org-of-bob", hasAuth: true }, + { phase: "deliver", principal: "alice", org: "org-of-alice", hasAuth: false }, + ]); + }); + + it("sends a personal event only to the users in `to`, and requires `to`", async () => { + const { events, subscribe, receiver } = setup(); + const exportFinished = events.define("export.finished", { + description: "Your export finished.", + payload: z.object({ exportId: z.string() }), + personal: true, + authorize: () => true, + }); + const alice = await subscribe({}, { user: "alice", name: "export.finished" }); + await subscribe({}, { user: "bob", name: "export.finished" }); + + // @ts-expect-error `to` is required for a personal event + const withoutTo = () => exportFinished.emit({ exportId: "e1" }); + await expect(withoutTo()).rejects.toThrow(/personal/); + + expect((await exportFinished.emit({ exportId: "e1" }, { to: "alice" })).matched).toBe(1); + expect(receiver.deliveries().map((d) => d.url)).toEqual([alice.url]); + }); +}); + +describe("define", () => { + it("requires authorize", () => { + const { events } = setup(); + expect(() => + events.define("open", { description: "d", payload: z.object({}) } as never), + ).toThrow(/authorize/); + }); + + it("requires args when the payload does not carry every input field", async () => { + const { events, subscribe, receiver } = setup(); + const replied = events.define("reply.posted", { + description: "A reply was posted in a thread.", + input: z.object({ threadId: z.string() }), + payload: z.object({ text: z.string() }), // no threadId in the payload + authorize: () => true, + }); + const sub = await subscribe({ threadId: "t1" }, { name: "reply.posted" }); + + // @ts-expect-error args is required: the payload has no threadId to match on + const withoutArgs = () => replied.emit({ text: "hi" }); + await expect(withoutArgs()).rejects.toThrow(/threadId/); + + expect((await replied.emit({ text: "hi" }, { args: { threadId: "t1" } })).matched).toBe(1); + expect(receiver.deliveries().map((d) => d.url)).toEqual([sub.url]); + }); + + it("derives the match values from the payload when it carries every input field", async () => { + const { subscribe, commentCreated, receiver } = setup(); + const sub = await subscribe({ documentId: "doc_9" }); + // No options at all: documentId comes from the payload. + expect( + (await commentCreated.emit({ documentId: "doc_9", author: "a", text: "t" })).matched, + ).toBe(1); + expect(receiver.deliveries()[0]?.url).toBe(sub.url); + }); +}); + describe("principal", () => { it("refuses to subscribe or unsubscribe a caller it cannot identify", async () => { const { subscribe, rpc, receiver } = setup(); @@ -452,7 +575,7 @@ describe("principal", () => { }); const sub = await subscribe({ documentId: "d" }, { user: null }); expect(sub.error).toBeUndefined(); - expect((await store.get(String(sub.result?.id)))?.owner).toBe("session-user"); + expect((await store.get(String(sub.result?.id)))?.subscriber).toBe("session-user"); }); it("is required", () => { diff --git a/packages/mcp-toolkit/src/events/core.ts b/packages/mcp-toolkit/src/events/core.ts index e44a22a..bcac7b9 100644 --- a/packages/mcp-toolkit/src/events/core.ts +++ b/packages/mcp-toolkit/src/events/core.ts @@ -1,15 +1,17 @@ /** * MCP Events for servers on the official TypeScript SDK: typed event definitions, the three - * `events/*` methods a host calls, and an owner-scoped `emit` that delivers signed Standard - * Webhooks POSTs. Webhook delivery only, as ChatGPT ships it. + * `events/*` methods a host calls, and an `emit` that delivers signed Standard Webhooks POSTs to + * the matching subscriptions `authorize` allows. Webhook delivery only, as ChatGPT ships it. */ import { ProtocolError, type McpServer } from "@modelcontextprotocol/server"; import * as z from "zod"; import { callerOf, - type Caller, requirePrincipal, + resolvePrincipal, + type Caller, type CallerAuth, + type Principal, type PrincipalResolver, } from "../shared/auth.js"; import { randomBase64Url, randomHex, sha256Hex } from "../shared/crypto.js"; @@ -37,7 +39,7 @@ const MAX_MATCH_KEYS = 8; /** The challenge echo is tiny; never read more than this from a callback. */ const MAX_CHALLENGE_RESPONSE_BYTES = 4096; -export type { Caller, CallerAuth }; +export type { Caller, CallerAuth, Principal }; export type EventLayerOptions = { store: SubscriptionStore; @@ -48,8 +50,9 @@ export type EventLayerOptions = { */ secretKey?: string; /** - * Who is calling, usually your user id. Required. Every subscription is owned by its caller, - * `undefined` refuses the subscribe, and `emit` only reaches the owners it names. + * Who is calling, usually your user id: `({ auth }) => auth?.extra?.userId`. Required; + * `undefined` refuses the subscribe. Return `{ id, context }` to store non-secret context (an + * org id) that `authorize` gets back before each delivery. Never put the token in it. */ principal: PrincipalResolver; defaults?: { @@ -69,7 +72,25 @@ export type EventLayerOptions = { /** Subscription arguments are an object schema, so they can be matched field by field. */ export type EventInputSchema = z.ZodObject; -export type EventConfig = { +/** What `authorize` is told about the subscriber. */ +export type AuthorizeCaller = { + /** The subscriber's id, from `principal`. */ + principal: string; + /** The context `principal` returned at subscribe time, or `{}`. */ + context: Record; + /** `"subscribe"` on every subscribe and refresh, `"deliver"` before every delivery. */ + phase: "subscribe" | "deliver"; + /** Only at subscribe time: no request stands behind a delivery, and the token is never stored. */ + auth?: CallerAuth; + /** Only at subscribe time. */ + request?: Request; +}; + +export type EventConfig< + Input extends EventInputSchema, + Payload extends z.ZodType, + Personal extends boolean = false, +> = { title?: string; /** What the event means, for the model and the user. */ description: string; @@ -78,31 +99,56 @@ export type EventConfig true` for an event any + * authenticated caller may hear. */ - authorize?: ( - args: z.output, - caller: { principal: string } & Caller, - ) => boolean | Promise; + authorize: (args: z.output, caller: AuthorizeCaller) => boolean | Promise; + /** + * A personal event belongs to specific users ("your export finished"), so every `emit` must say + * who with `to`. The type makes `to` required, and so does a runtime check. + */ + personal?: Personal; /** An extra filter for what exact argument matching cannot express. Runs per subscription. */ match?: (args: z.output, payload: z.output) => boolean; }; -/** Who receives an event: one owner, or several (e.g. everyone on a document). */ -export type EmitRecipients = - | { owner: string; owners?: never } - | { owners: readonly string[]; owner?: never }; +/** One user id, or several. */ +export type Recipients = string | readonly string[]; -export type EmitOptions = EmitRecipients & { - /** - * The values subscriptions are matched on. `{ repo: "a", branch: "main" }` reaches subscribers - * of `{ repo: "a" }`, of both, and of `{}`. Defaults to the payload fields named in the input. - */ - args?: Partial; - /** Stable across retries and sent as `webhook-id`. Reuse an id to deduplicate an emit. */ +/** + * `args` is optional when every input field is also a payload field (the match values are read + * from the payload), and required otherwise, so a filter can never silently match nothing. + */ +type ArgsOption = string extends keyof Input + ? { args?: Partial } // no input schema: nothing to match on + : [Exclude] extends [never] + ? { args?: Partial } + : { args: Input }; + +type ToOption = Personal extends true + ? { to: Recipients } + : { to?: Recipients }; + +export type EmitOptions = ArgsOption< + Input, + Payload +> & + ToOption & { + /** Stable across retries and sent as `webhook-id`. Reuse an id to deduplicate an emit. */ + eventId?: string; + /** Defaults to now. */ + timestamp?: Date; + }; + +/** The options argument may be left out when none of its fields are required. */ +type OptionsArg = Partial extends T ? [options?: T] : [options: T]; + +type AnyEmitOptions = { + args?: Record; + to?: Recipients; eventId?: string; - /** Defaults to now. */ timestamp?: Date; }; @@ -112,10 +158,20 @@ export type EmitResult = { matched: number; }; -export type EventHandle = { +export type EventHandle< + Input extends EventInputSchema, + Payload extends z.ZodType, + Personal extends boolean = false, +> = { readonly name: string; - /** Validates the payload and delivers it to the named owners' matching subscriptions. */ - emit(payload: z.input, options: EmitOptions>): Promise; + /** + * Validates the payload and hands it to every matching subscription, or only those of the users + * in `to`. `authorize` is checked again before each delivery. + */ + emit( + payload: z.input, + ...options: OptionsArg, z.output, Personal>> + ): Promise; }; /** What `events/list` returns for each event. */ @@ -133,10 +189,11 @@ export type EventLayer = { define< Input extends EventInputSchema = z.ZodObject>, Payload extends z.ZodType = z.ZodType, + Personal extends boolean = false, >( name: string, - config: EventConfig, - ): EventHandle; + config: EventConfig, + ): EventHandle; /** Declares the `events` capability and serves `events/list`, `/subscribe` and `/unsubscribe`. */ register(server: McpServer): void; /** The transport's delivery endpoint: `export const POST = events.createDeliveryHandler()`. */ @@ -154,8 +211,10 @@ export class EventPayloadTooLargeError extends Error { } type Definition = { - config: EventConfig; + config: EventConfig; input: EventInputSchema; + /** Input fields a subscriber must give, so an emit must have a value for them. */ + required: string[]; descriptor: EventDescriptor; }; @@ -211,15 +270,27 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const definitions = new Map(); - function define( + function define< + Input extends EventInputSchema, + Payload extends z.ZodType, + Personal extends boolean = false, + >( name: string, - config: EventConfig, - ): EventHandle { + config: EventConfig, + ): EventHandle { if (definitions.has(name)) throw new Error(`Event "${name}" is already defined`); + if (typeof config.authorize !== "function") { + throw new Error( + `Event "${name}" needs \`authorize\`: who may subscribe and receive it. Pass \`() => true\` for an event any authenticated caller may hear.`, + ); + } const input = (config.input ?? z.object({})) as EventInputSchema; definitions.set(name, { - config: config as unknown as EventConfig, + config: config as unknown as EventConfig, input, + required: Object.entries(input.shape) + .filter(([, field]) => !(field as z.ZodType).safeParse(undefined).success) + .map(([key]) => key), descriptor: { name, ...(config.title ? { title: config.title } : {}), @@ -231,29 +302,35 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { }); return { name, - emit: (payload, emitOptions) => emit(name, payload, emitOptions as EmitOptions), + emit: (payload, ...rest) => emit(name, payload, rest[0] as AnyEmitOptions | undefined), }; } async function emit( name: string, rawPayload: unknown, - emitOptions: EmitOptions, + emitOptions: AnyEmitOptions = {}, ): Promise { const definition = definitions.get(name); if (!definition) throw new Error(`Unknown event "${name}"`); - const owners = - emitOptions?.owners ?? (emitOptions?.owner === undefined ? undefined : [emitOptions.owner]); - if (!owners) { - throw new Error( - `emit("${name}") needs \`owner\` or \`owners\`: events only reach the subscriptions of the users you name.`, - ); + const to = emitOptions.to; + const recipients = to === undefined ? undefined : new Set(typeof to === "string" ? [to] : to); + if (definition.config.personal && !recipients) { + throw new Error(`"${name}" is a personal event: emit it with \`to\`.`); } const payload = definition.config.payload.parse(rawPayload); const args = (emitOptions.args ?? pickArgs(definition.input, payload)) as Record< string, unknown >; + for (const key of definition.required) { + // Every subscriber filtered on it, so without a value the event could match nobody. + if (args[key] === undefined) { + throw new Error( + `emit("${name}") has no value for "${key}": put it in the payload or in \`args\`.`, + ); + } + } const envelope: EventEnvelope = { eventId: emitOptions.eventId ?? `evt_${randomHex(12)}`, name, @@ -263,13 +340,14 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { }; const bytes = new TextEncoder().encode(JSON.stringify(envelope)).length; if (bytes > MAX_PAYLOAD_BYTES) throw new EventPayloadTooLargeError(bytes); - if (owners.length === 0) return { eventId: envelope.eventId, matched: 0 }; + if (recipients?.size === 0) return { eventId: envelope.eventId, matched: 0 }; - const candidates = await store.find(name, [...new Set(owners)], matchKeys(args)); const match = definition.config.match; - const targets = match - ? candidates.filter((sub) => match(sub.args as never, payload as never)) - : candidates; + const targets = (await store.find(name, matchKeys(args))).filter( + (sub) => + (!recipients || recipients.has(sub.subscriber)) && + (!match || match(sub.args as never, payload as never)), + ); if (targets.length > 0) { await delivery.enqueue(targets.map((sub) => ({ subscriptionId: sub.id, envelope }))); } @@ -278,7 +356,20 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { async function send(job: DeliveryJob): Promise { const sub = await store.get(job.subscriptionId); - if (!sub) return "dropped"; + const definition = sub && definitions.get(sub.event); + if (!sub || !definition) return "dropped"; + // Access can be revoked after subscribing, so ask again before every delivery. + let allowed: boolean; + try { + allowed = await definition.config.authorize(sub.args as never, { + principal: sub.subscriber, + context: sub.context ?? {}, + phase: "deliver", + }); + } catch { + return "retry"; + } + if (!allowed) return "dropped"; const secret = await openOrEmpty(sub.encryptedSecret); if (!secret) return "dropped"; // sealed under a rotated key: wait for the host's refresh let response: Response; @@ -336,11 +427,18 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { throw invalid(problem ?? "callback URL is missing", "invalid_callback_url"); const caller = callerOf(ctx); - const owner = await principal(caller); - if (owner === undefined) throw notAuthenticated(); + const who = await resolvePrincipal(principal, caller); + if (who === undefined) throw notAuthenticated(); + const owner = who.id; if ( definition.config.authorize && - !(await definition.config.authorize(args, { principal: owner, ...caller })) + !(await definition.config.authorize(args, { + principal: owner, + context: who.context, + phase: "subscribe", + auth: caller.auth, + request: caller.request, + })) ) { throw invalid("Not authorized to subscribe with these arguments", "not_authorized"); } @@ -364,7 +462,8 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { url, encryptedSecret: verified && existing ? existing.encryptedSecret : await secretBox().seal(secret), - owner, + subscriber: owner, + ...(Object.keys(who.context).length > 0 ? { context: who.context } : {}), createdAt: existing?.createdAt ?? new Date(now).toISOString(), expiresAt, }); @@ -377,7 +476,7 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { } async function unsubscribe(params: z.output, ctx: unknown) { - const owner = await principal(callerOf(ctx)); + const owner = (await resolvePrincipal(principal, callerOf(ctx)))?.id; if (owner === undefined) throw notAuthenticated(); const parsed = definitions.get(params.name)?.input.safeParse(params.arguments ?? {}); if (parsed?.success && params.delivery.url) { @@ -386,7 +485,6 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { id: await subscriptionId(owner, params.delivery.url, params.name, args), event: params.name, argsKey: canonicalJson(args), - owner, }); } return {}; @@ -485,14 +583,14 @@ export function matchKeys(args: Record): string[] { return keys; } -/** Deterministic, as the draft asks: same owner, callback, event and arguments give the same id. */ +/** Deterministic, as the draft asks: same subscriber, callback, event and arguments give the same id. */ export async function subscriptionId( - owner: string, + subscriber: string, url: string, event: string, args: Record, ): Promise { - return `sub_${(await sha256Hex(canonicalJson([owner, url, event, args]))).slice(0, 32)}`; + return `sub_${(await sha256Hex(canonicalJson([subscriber, url, event, args]))).slice(0, 32)}`; } function pickArgs(input: EventInputSchema, payload: unknown): Record { diff --git a/packages/mcp-toolkit/src/events/index.ts b/packages/mcp-toolkit/src/events/index.ts index 59d13ee..fb0e813 100644 --- a/packages/mcp-toolkit/src/events/index.ts +++ b/packages/mcp-toolkit/src/events/index.ts @@ -12,9 +12,11 @@ export { CALLBACK_ENDPOINT_ERROR, MAX_PAYLOAD_BYTES, type Caller, + type Principal, type CallerAuth, type EmitOptions, - type EmitRecipients, + type AuthorizeCaller, + type Recipients, type EmitResult, type EventConfig, type EventDescriptor, diff --git a/packages/mcp-toolkit/src/events/tasks.ts b/packages/mcp-toolkit/src/events/tasks.ts index e32451d..07e4344 100644 --- a/packages/mcp-toolkit/src/events/tasks.ts +++ b/packages/mcp-toolkit/src/events/tasks.ts @@ -42,6 +42,10 @@ export function taskFinishedEvent( "A long-running task you started finished: completed, failed or was cancelled. Carries the result when it is small enough.", input: taskFinishedInput, payload: taskFinishedPayload, + // Personal: each emit goes `to` the task's owner, so a subscription with no arguments only + // hears about its own tasks. Nothing else to check. + personal: true, + authorize: () => true, }); async function onSettle(task: Task): Promise { @@ -56,7 +60,7 @@ export function taskFinishedEvent( ...(task.error ? { error: task.error } : {}), }; const emitOptions = { - owner: task.owner, + to: task.owner, args: { taskId: task.taskId }, // Stable per task record, so a repeated settle hook cannot deliver twice, while a keyed task // re-created after expiry (same id, new createdAt) is not mistaken for a duplicate. diff --git a/packages/mcp-toolkit/src/events/types.ts b/packages/mcp-toolkit/src/events/types.ts index 5288375..4180167 100644 --- a/packages/mcp-toolkit/src/events/types.ts +++ b/packages/mcp-toolkit/src/events/types.ts @@ -18,7 +18,7 @@ export type EventEnvelope = { /** One webhook subscription, as stored. */ export type Subscription = { - /** Derived from the owner, callback URL, event name and arguments. */ + /** Derived from the subscriber, callback URL, event name and arguments. */ id: string; event: string; /** The validated subscription arguments. */ @@ -29,8 +29,10 @@ export type Subscription = { url: string; /** The `whsec_` signing secret, encrypted with the layer's `secretKey`. */ encryptedSecret: string; - /** The caller that subscribed. Events only reach subscriptions of the owners they name. */ - owner: string; + /** Who subscribed: the id `principal` returned. */ + subscriber: string; + /** Non-secret context `principal` returned, handed back to `authorize` before each delivery. */ + context?: Record; /** ISO-8601. */ createdAt: string; /** Epoch milliseconds. */ @@ -38,11 +40,11 @@ export type Subscription = { }; /** The fields that locate a subscription in a store's index. */ -export type SubscriptionRef = Pick; +export type SubscriptionRef = Pick; /** - * Durable storage for subscriptions, indexed by `(event, owner, argsKey)`. The layer computes - * every key an emit can match, so a store needs no matching logic of its own. + * Durable storage for subscriptions, indexed by `(event, argsKey)`. The layer computes every key + * an emit can match, so a store needs no matching logic of its own. */ export interface SubscriptionStore { /** Creates or replaces a subscription. Must have committed before it resolves. */ @@ -51,12 +53,8 @@ export interface SubscriptionStore { get(id: string): Promise; /** Removes a subscription and its index entry. A no-op when it does not exist. */ delete(subscription: SubscriptionRef): Promise; - /** Every live subscription to `event` owned by one of `owners`, with one of `argsKeys`. */ - find( - event: string, - owners: readonly string[], - argsKeys: readonly string[], - ): Promise; + /** Every live subscription to `event` whose `argsKey` is one of `argsKeys`. */ + find(event: string, argsKeys: readonly string[]): Promise; } /** One envelope for one subscription. */ @@ -71,14 +69,14 @@ export type SendOutcome = | "delivered" /** 410: the host dropped the subscription, and the layer deleted it. */ | "gone" - /** 413, a redirect, or a subscription that no longer exists: retrying cannot help. */ + /** 413, a redirect, a missing subscription, or `authorize` now refuses it: retrying cannot help. */ | "dropped" /** Anything else: retry. */ | "retry"; /** The layer's entry point, handed to a transport by {@link EventDelivery.attach}. */ export type DeliveryEndpoints = { - /** Signs the envelope fresh, POSTs it and classifies the answer. Call it once per attempt. */ + /** Re-checks `authorize`, signs the envelope fresh, POSTs it and classifies the answer. */ send(job: DeliveryJob): Promise; }; diff --git a/packages/mcp-toolkit/src/shared/auth.ts b/packages/mcp-toolkit/src/shared/auth.ts index 14c2849..1f1cdf6 100644 --- a/packages/mcp-toolkit/src/shared/auth.ts +++ b/packages/mcp-toolkit/src/shared/auth.ts @@ -23,13 +23,22 @@ export type Caller = { request: Request | undefined; }; +/** + * The caller's id, or the id plus non-secret context (an org id, a role) that event subscriptions + * store and hand back to `authorize` before each delivery. Never put the token in it. + */ +export type Principal = string | { id: string; context?: Record }; + /** * Maps a call to a stable caller id, usually your user id. `undefined` means "not * authenticated", and the call is refused. May be async, e.g. to look up a session. */ export type PrincipalResolver = ( caller: Caller, -) => string | undefined | Promise; +) => Principal | undefined | Promise; + +/** The most JSON a principal's context may take, since it is stored with every subscription. */ +export const MAX_CONTEXT_BYTES = 4096; /** The auth and request an SDK request handler context carries. */ export function callerOf(context: unknown): Caller { @@ -37,6 +46,21 @@ export function callerOf(context: unknown): Caller { return { auth: http?.authInfo, request: http?.req }; } +/** Runs the resolver and normalizes its answer to `{ id, context }`, or `undefined`. */ +export async function resolvePrincipal( + principal: PrincipalResolver, + caller: Caller, +): Promise<{ id: string; context: Record } | undefined> { + const result = await principal(caller); + if (result === undefined || result === null) return undefined; + if (typeof result === "string") return { id: result, context: {} }; + const context = result.context ?? {}; + if (new TextEncoder().encode(JSON.stringify(context)).length > MAX_CONTEXT_BYTES) { + throw new Error(`principal context must be at most ${MAX_CONTEXT_BYTES} bytes of JSON`); + } + return { id: result.id, context }; +} + /** Throws at construction when a layer is built without a principal. */ export function requirePrincipal(principal: unknown, layer: string): PrincipalResolver { if (typeof principal !== "function") { diff --git a/packages/mcp-toolkit/src/tasks/core.ts b/packages/mcp-toolkit/src/tasks/core.ts index 7d60b5e..0d05853 100644 --- a/packages/mcp-toolkit/src/tasks/core.ts +++ b/packages/mcp-toolkit/src/tasks/core.ts @@ -9,6 +9,7 @@ import { callerOf as callerFrom, type Caller, requirePrincipal, + resolvePrincipal, type CallerAuth, type PrincipalResolver, } from "../shared/auth.js"; @@ -144,7 +145,8 @@ export function createTaskLayer( >(); const wired = new WeakSet(); - const callerOf = async (context: unknown) => await principal(callerFrom(context)); + const callerOf = async (context: unknown) => + (await resolvePrincipal(principal, callerFrom(context)))?.id; async function runSettleHook(task: Task): Promise { if (!onSettle) return; diff --git a/packages/mcp-toolkit/src/tasks/index.ts b/packages/mcp-toolkit/src/tasks/index.ts index d22dcec..7fe0f43 100644 --- a/packages/mcp-toolkit/src/tasks/index.ts +++ b/packages/mcp-toolkit/src/tasks/index.ts @@ -15,7 +15,7 @@ export { type TaskToolConfig, } from "./core.js"; -export type { PrincipalResolver } from "../shared/auth.js"; +export type { Principal, PrincipalResolver } from "../shared/auth.js"; export { dispatchKey, From dd7bc7a882af05ea77694bc122ec9298e1d272ec Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 12:39:41 +0000 Subject: [PATCH 23/33] DX-3022: mcp-toolkit README: what lives in Redis Every key the toolkit writes, what it is for, and what it means for security (plaintext args/results, encrypted signing secrets, no tokens stored, owner and subscriber checks) and correctness (create-if-absent, guarded terminal writes, TTLs, index expiry, deterministic ids, bounded lookups). Also what sits in QStash. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK --- packages/mcp-toolkit/README.md | 110 ++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 3 deletions(-) diff --git a/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md index 4468a07..c733e6f 100644 --- a/packages/mcp-toolkit/README.md +++ b/packages/mcp-toolkit/README.md @@ -545,6 +545,112 @@ Cursor and OpenCode do not subscribe yet. The demo's Deploy Watch server has bee with ChatGPT monitors. Poll and stream delivery modes in the draft are not implemented here; `events/subscribe` refuses them. +## What lives in Redis + +Everything the toolkit keeps is in your Upstash Redis database, under two prefixes you can change +(`prefix` on each store). This section lists every key, what it is for, and what it means for +security and correctness. Treat the Redis credentials like any other production secret: whoever can +write these keys can change who owns a task or where an event goes. + +### Tasks: one hash per task + +`mcp:task:` is a hash with one field per task property, each value JSON-encoded: + +| Field | What it is | +| --- | --- | +| `taskId`, `name` | The task id, and the defined task (tool) name that picks the handler | +| `args` | The validated tool arguments, replayed into the handler on delivery | +| `owner` | The caller's id from `principal`, checked on every `task_status` / `task_cancel` | +| `status`, `statusMessage` | `working`, `completed`, `failed` or `cancelled`, and the progress line | +| `result` / `error` | The handler's tool result once `completed`, or the error once `failed` | +| `createdAt`, `lastUpdatedAt`, `ttlMs`, `pollIntervalMs` | Timing | +| `dispatchId` | The QStash message id or Workflow run id, so a cancel can stop pending deliveries | + +**Security** + +- `args` and `result` are **plain JSON**. Anyone who can read the database can read them, for as + long as the task lives. Keep secrets out of tool arguments and results, or use a short `ttlMs`. +- `owner` is what keeps tasks apart. A task is only ever answered to the caller whose `principal` + matches it; another caller's id reads as unknown. That is why `principal` must return the user + (the token's subject), not `auth.clientId`. +- The caller's token, `AuthInfo` and request are **never stored**. +- Keyed task ids (`idempotencyKey`) are a hash of the owner, the tool and the key, so two callers + can never land on the same record. + +**Correctness** + +- **Durable before the reply.** The record is written before the tool answers with its id, because + the model may poll from another instance right away. Creation is one Lua script that writes only + if the key is absent, so two racing retries with the same key cannot both create a task, and a + retry gets the existing task back. +- **First terminal write wins.** Progress updates and terminal transitions go through one guarded + Lua script that refuses to touch a task that is already `completed`, `failed` or `cancelled`. A + completion that lands after a cancel cannot overwrite it, and a late progress line cannot + overwrite "Cancelled by client". +- **One field per property**, so a progress write and a cancel never clobber each other's fields. +- **TTL from creation.** `PEXPIRE` is set once, from `ttlMs` (5 minutes by default), and never + extended. An expired task reads as unknown, and its handler's `isCancelled()` returns true. With + `ttlMs: null` the record has no expiry and stays until you delete it. +- **Values are JSON-encoded on write**, so the client's automatic decoding is the exact inverse: a + status message of `"123"` comes back as a string. A Redis client built with + `automaticDeserialization: false` is not supported. + +### Events: one key per subscription, one index per filter + +`mcp-events:sub:` is a JSON string with the subscription: + +| Field | What it is | +| --- | --- | +| `id` | `sub_` + a hash of subscriber, callback URL, event and arguments | +| `event`, `args`, `argsKey` | The event name, the subscriber's filter, and its canonical JSON | +| `url` | The callback URL the host gave, which every delivery for this subscription goes to | +| `encryptedSecret` | The host's `whsec_` signing secret, AES-256-GCM encrypted under `secretKey` | +| `subscriber` | The caller's id from `principal` | +| `context` | Optional non-secret context `principal` returned, at most 4 KB | +| `createdAt`, `expiresAt` | Timing | + +`mcp-events:idx::` is a sorted set of subscription ids scored by expiry, +so an emit reads only the subscriptions its arguments can match. + +**Security** + +- **The signing secret is encrypted** with `secretKey` (`MCP_EVENTS_SECRET_KEY`), which is never + stored in Redis. A leaked database does not let anyone forge webhooks to the hosts. Rotating the + key makes stored secrets unreadable; those subscriptions stop receiving events until the host + refreshes and re-verifies. +- **No token is stored.** That is why `authorize` before a delivery only gets the stored + `subscriber` and `context`, never `auth`. Put only non-secret data (an org id, a role) in + `context`. +- **Callback URLs are visible** to anyone who can read the database. They are host-generated and + only accept requests signed with the encrypted secret. +- **Where vs who.** The callback URL says where a delivery goes; `subscriber` plus `authorize` say + who may receive it. One user connected through two hosts has two subscriptions with two URLs and + two secrets, so a delivery for one host is never sent to the other. +- A subscription is stored only after `authorize` allows it and the callback echoed a signed + challenge. Unsubscribing recomputes the id from the caller's own principal, so only the + subscriber can remove it. + +**Correctness** + +- **Expiry.** The subscription key expires with the subscription (`PX`), and the index is written in + the same Lua script, kept alive as long as its longest-lived member, with expired members pruned + on every write. Reads only take index entries scored in the future. +- **Deterministic ids.** Subscribing again with the same subscriber, URL, event and arguments + updates the same record (a refresh) instead of creating a duplicate. +- **Bounded lookups.** An emit reads one index per subset of its arguments (at most 256), then the + matching records, in requests of at most 1,000 commands. + +### Not in Redis, but worth knowing + +- **QStash messages.** A task delivery carries only `{ taskId }`. An event delivery carries the + subscription id and the full event envelope, including its payload, so the payload sits in QStash + (and its DLQ, if every retry fails) until it is delivered. +- **Deduplication keys.** Task dispatches dedupe on `taskId` plus creation time, and event deliveries + on event id plus subscription id. Re-creating an expired keyed task gets a new key, so QStash's + 10-minute dedup window never swallows it. +- **Never stored anywhere by the toolkit:** the caller's token or `AuthInfo`, the raw request, the + plaintext webhook secret, and `MCP_EVENTS_SECRET_KEY`. + ## Reference
@@ -597,9 +703,7 @@ neither is durable, which is exactly the failure this package is about. **`RedisTaskStore`** — `redis` (defaults to `Redis.fromEnv()`; `automaticDeserialization: false` is not supported), `prefix`, `enableTelemetry`. -**Data at rest.** Task `args` and `result` are stored in Redis as plain JSON for the task's TTL, -which is forever with `ttlMs: null`. Don't put secrets in tool arguments or results, or set a short -TTL. Webhook signing secrets, by contrast, are encrypted with `secretKey`. +**Data at rest.** See [What lives in Redis](#what-lives-in-redis). **`QStashDispatcher`** — `url` required; `qstash`, `receiver`, `retries`, `retryDelay`, `headers`, `enableTelemetry`. From b2cedb883a68192df68bf748d726e6db4f6cf1f1 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 12:46:13 +0000 Subject: [PATCH 24/33] DX-3022: mcp-toolkit README: say where the owner lives in a task id Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A5x4btgiRE3FKdpUW527Wu --- packages/mcp-toolkit/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md index c733e6f..9a4bbdb 100644 --- a/packages/mcp-toolkit/README.md +++ b/packages/mcp-toolkit/README.md @@ -574,8 +574,11 @@ write these keys can change who owns a task or where an event goes. matches it; another caller's id reads as unknown. That is why `principal` must return the user (the token's subject), not `auth.clientId`. - The caller's token, `AuthInfo` and request are **never stored**. -- Keyed task ids (`idempotencyKey`) are a hash of the owner, the tool and the key, so two callers - can never land on the same record. +- The owner is not a separate part of the key; it is folded into the task id. A task without an + `idempotencyKey` gets a random id (`crypto.randomUUID()`). A keyed task's id is the first 32 hex + characters of `sha256(JSON.stringify([owner, tool, key]))`, so alice and bob calling + `generate_report` with the same key get different ids, and so different records. Either way the + stored `owner` field is checked again on every read. **Correctness** From eccb1e697ff7664a362c36e794d2de8be37dbf05 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 12:49:07 +0000 Subject: [PATCH 25/33] DX-3022: mcp-toolkit README: who can see what Why a user can't read or cancel another user's task, why only users with access can subscribe, and how authorize is re-checked before every delivery. Plus what the toolkit does not cover. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A5x4btgiRE3FKdpUW527Wu --- packages/mcp-toolkit/README.md | 75 ++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md index 9a4bbdb..fc1540d 100644 --- a/packages/mcp-toolkit/README.md +++ b/packages/mcp-toolkit/README.md @@ -545,6 +545,81 @@ Cursor and OpenCode do not subscribe yet. The demo's Deploy Watch server has bee with ChatGPT monitors. Poll and stream delivery modes in the draft are not implemented here; `events/subscribe` refuses them. +## Who can see what + +Every guarantee below starts from `principal`. It runs on the server, on every request, and reads +the `AuthInfo` your route verified (or the request, for session apps). The caller never supplies an +owner or a subscriber id: no tool argument, subscription argument or header is trusted for it. So +the guarantees are only as good as `principal`: it must return the user (the token's subject), not +`auth.clientId`, which every user of a host like ChatGPT shares. + +### Tasks: nobody can read or cancel another user's task + +1. **The owner is set by the server.** When a task tool is called, the toolkit runs `principal` + and stores the result as the task's `owner`. The model only sends the tool's own arguments, and + there is no way to pass an owner. A caller `principal` cannot identify is refused before + anything is stored. +2. **Every read checks it.** `task_status` and `task_cancel` run `principal` again, load the task, + and compare its `owner` to the caller. Anything else gets the same answer as an id that never + existed ("Unknown task"), so another user's id cannot even be confirmed to exist. +3. **Ids don't help.** Unkeyed ids are random UUIDs. Keyed ids hash the owner in, so the same + `idempotencyKey` gives each user their own task and never someone else's. +4. **There is no other way in.** No tool lists tasks. The execute endpoint only accepts deliveries + signed by QStash, and it runs the handler; it never returns a task to the caller. +5. **`task.finished` follows the same owner.** It is a personal event, emitted `to` the task's + owner, so a task's result only reaches that user's subscriptions. + +### Events: only users with access can subscribe + +On every `events/subscribe`: + +1. `principal` gives the subscriber. If it is `undefined`, the call is refused with + `not_authenticated`. +2. The arguments are validated against the event's `input` schema. +3. `authorize(args, { principal, context, phase: "subscribe", auth, request })` runs. It is + required on every event. If it says no, the call is refused with `not_authorized`. This happens + **before** the callback is challenged and before anything is stored, so a refused caller costs + one call and leaves nothing behind. +4. Only then is the callback verified and the subscription stored, with the subscriber's id in its + record and in its id. + +A refresh is a subscribe with the same arguments, so it goes through all four steps again. An +unsubscribe recomputes the subscription id from the caller's own principal, so a user can only ever +remove their own subscriptions. + +### Events: deliveries only reach users who still have access + +Subscribing was allowed once, but access changes. So the check runs again for every delivery: + +1. **`emit` picks candidates.** It finds the subscriptions whose arguments match, and, when `to` is + given, keeps only those users' subscriptions. A `personal` event cannot be emitted without `to`; + the type requires it, and so does a runtime check. `emit` is your server code, never something a + client calls. +2. **Each candidate is queued separately**, one QStash message per subscription. +3. **The delivery route checks again.** When QStash calls it, the route loads the subscription and + runs `authorize(args, { principal: subscriber, context, phase: "deliver" })`. Because this + happens at delivery time, not at `emit`, it also catches access removed between the two. + - **No:** the event is dropped. Nothing is sent, and QStash does not retry it. + - **Throws** (your database is down, say): the route answers 500 and QStash retries later, so a + temporary failure never turns into a delivery. + - **Yes:** the envelope is signed with that subscription's own secret and POSTed to that + subscription's own callback URL. + +At delivery there is no token or request (neither is ever stored), so `authorize` decides from the +stored subscriber id, the subscription's arguments and the optional `context`, by asking your own +data: "can this user still read this document?" Treat `context` as a snapshot from subscribe time; +revocation checks should look up the current state. + +### What this does not cover + +- **Your `authorize`.** The toolkit makes sure it runs; whether it is right is yours. An event with + `authorize: () => true` reaches every authenticated subscriber whose arguments match. +- **Field-level redaction.** `authorize` lets a whole event through or not. Every recipient of an + emit gets the same payload, so don't put data in it that only some of them may see; emit + separately, with `to`, instead. +- **The Redis and QStash credentials.** Anyone who can write the stored records can change an owner + or a callback URL. See [What lives in Redis](#what-lives-in-redis). + ## What lives in Redis Everything the toolkit keeps is in your Upstash Redis database, under two prefixes you can change From 706f4187b577d65cf4f7ac40d2d0b3319571776e Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 13:05:50 +0000 Subject: [PATCH 26/33] DX-3022: mcp-toolkit: /upstash entry point, principal never undefined, drop idempotencyKey - /tasks and /events are generic (layers, interfaces, memory backends); every Upstash backend for both is in @upstash/mcp-toolkit/upstash. /tasks/upstash is gone. - @upstash/redis, @upstash/qstash and @upstash/workflow are regular dependencies, so the package works without extra installs; only the MCP SDK stays a peer. - principal is typed Principal | Promise: it throws to refuse. The layers still fail closed on a rejection or an answer without a non-empty string id. - idempotencyKey removed (add back if someone asks): task ids are always random, so dispatches and task.finished dedupe on the task id and dispatchKey is gone. - The Workflow context is inferred from the dispatcher; no createTaskLayer. - README: quickstart shows task.update / isCancelled inline, a full Workflow task example, and the new imports. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A5x4btgiRE3FKdpUW527Wu --- .changeset/mcp-toolkit-initial.md | 20 +- CLAUDE.md | 34 +-- .../mcp-toolkit-demo/app/lib/deploy-watch.ts | 14 +- .../mcp-toolkit-demo/app/lib/qstash-server.ts | 72 +++--- .../app/lib/workflow-server.ts | 73 +++--- packages/mcp-toolkit/README.md | 208 +++++++++--------- packages/mcp-toolkit/package.json | 32 +-- packages/mcp-toolkit/src/events/core.test.ts | 50 +---- packages/mcp-toolkit/src/events/core.ts | 4 +- packages/mcp-toolkit/src/events/index.ts | 11 +- packages/mcp-toolkit/src/events/tasks.ts | 7 +- packages/mcp-toolkit/src/shared/auth.ts | 33 ++- .../src/tasks/backends/qstash.test.ts | 16 +- .../mcp-toolkit/src/tasks/backends/qstash.ts | 4 +- .../src/tasks/backends/workflow.test.ts | 29 ++- .../src/tasks/backends/workflow.ts | 12 +- packages/mcp-toolkit/src/tasks/core.test.ts | 77 +++---- packages/mcp-toolkit/src/tasks/core.ts | 34 +-- packages/mcp-toolkit/src/tasks/index.ts | 3 +- packages/mcp-toolkit/src/tasks/types.ts | 7 +- packages/mcp-toolkit/src/tasks/upstash.ts | 15 -- packages/mcp-toolkit/src/test-support.ts | 7 + packages/mcp-toolkit/src/upstash.ts | 26 +++ packages/mcp-toolkit/tsup.config.ts | 2 +- pnpm-lock.yaml | 69 +----- 25 files changed, 367 insertions(+), 492 deletions(-) delete mode 100644 packages/mcp-toolkit/src/tasks/upstash.ts create mode 100644 packages/mcp-toolkit/src/upstash.ts diff --git a/.changeset/mcp-toolkit-initial.md b/.changeset/mcp-toolkit-initial.md index 97af8da..5b933f1 100644 --- a/.changeset/mcp-toolkit-initial.md +++ b/.changeset/mcp-toolkit-initial.md @@ -7,21 +7,23 @@ Add `@upstash/mcp-toolkit`: durable building blocks for MCP servers on the offic `@upstash/mcp-toolkit/tasks` — long-running tools. A task tool answers immediately with a task id; the model polls the shared `task_status` tool for progress and the result, and can stop the task with `task_cancel`. They are ordinary MCP tools, so they work in every client today. The record -lives in a `TaskStore` and the work runs behind a `TaskDispatcher`; `/tasks/upstash` provides -`RedisTaskStore`, `QStashDispatcher` and `WorkflowDispatcher`, and the dispatcher owns its delivery +lives in a `TaskStore` and the work runs behind a `TaskDispatcher`, and the dispatcher owns its delivery endpoint (`export const POST = tasks.createExecuteHandler()`) and always verifies QStash's signature. Tasks are declared once at module scope with `tasks.define(...)` and attached to each request's server with `tasks.register(server)`. A required `principal` scopes tasks to their -caller (and subscriptions to theirs), `idempotencyKey` turns a retried start into a read of the existing task, and `onSettle` -fires once per finished task. +caller (and subscriptions to theirs), and `onSettle` fires once per finished task. `@upstash/mcp-toolkit/events` — MCP Events with webhook delivery, as ChatGPT ships it. Typed -`events.define(...)` handles with an owner-scoped `emit(payload, { owner })`, `events/list`, `events/subscribe` and +`events.define(...)` handles with `emit(payload)`, a required `authorize` checked at subscribe and +again before every delivery, `events/list`, `events/subscribe` and `events/unsubscribe` registered on your server, a signed verification challenge, deterministic subscription ids, expiry and refresh, SSRF checks on callback URLs, and signing secrets encrypted -at rest. It ships `RedisSubscriptionStore` and `QStashDelivery` in the same entry point; the latter signs each -attempt with Standard Webhooks and lets QStash retry failures (`export const POST = -events.createDeliveryHandler()`). `taskFinishedEvent` bridges the two, so an event-capable host can +at rest. `QStashDelivery` signs each attempt with Standard Webhooks and lets QStash retry failures +(`export const POST = events.createDeliveryHandler()`). `taskFinishedEvent` bridges the two, so an event-capable host can stop polling. -Both entry points use WebCrypto only, so they run on Node and edge runtimes. +The package uses WebCrypto only, so they run on Node and edge runtimes. + +`@upstash/mcp-toolkit/upstash` holds the Upstash backends for both: `RedisTaskStore`, +`QStashDispatcher`, `WorkflowDispatcher`, `RedisSubscriptionStore` and `QStashDelivery`. +`@upstash/redis`, `@upstash/qstash` and `@upstash/workflow` are regular dependencies. diff --git a/CLAUDE.md b/CLAUDE.md index cc6efb3..063d5f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ 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-tanstack-ai` (`packages/tanstack-ai`) | TanStack AI backends: persistence stores, `StreamDurability`, `LockStore`, `MemoryAdapter`, middlewares, search tools. | | `@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-toolkit` (`packages/mcp-toolkit`) | Durable building blocks for the official `@modelcontextprotocol/server` v2: `/tasks` (**long-running MCP tools** — start + `task_status` + `task_cancel`) and `/events` (**MCP Events**, webhook delivery). `/tasks` has a `/tasks/upstash` entry point; `/events` exports its Redis/QStash backends directly (no `/events/upstash`). Renamed from `@upstash/mcp-tasks` before its first release. **Not an `agentkit-*` package** — separate name, versioned independently (the changesets `linked` glob only covers `@upstash/agentkit-*`), and it depends on none of the others. | +| `@upstash/mcp-toolkit` (`packages/mcp-toolkit`) | Durable building blocks for the official `@modelcontextprotocol/server` v2: `/tasks` (**long-running MCP tools** — start + `task_status` + `task_cancel`) and `/events` (**MCP Events**, webhook delivery). `/tasks` and `/events` are generic (layers, interfaces, memory backends); every Upstash backend for both is in `/upstash` (no `/tasks/upstash`, no `/events/upstash`). `@upstash/redis`, `@upstash/qstash` and `@upstash/workflow` are regular dependencies (the package "just works"); only `@modelcontextprotocol/server` is a peer. Renamed from `@upstash/mcp-tasks` before its first release. **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), `eve-extension-demo` (a minimal eve scaffold that mounts the extension), and `mcp-toolkit-demo` @@ -679,9 +679,10 @@ implements TanStack AI's own backend contracts (see its section below) — keep `$count`, `$histogram`, `$percentiles`, `$cardinality`. ## MCP toolkit facts (`packages/mcp-toolkit`) — IMPORTANT -- **Layout:** `src/tasks/` (`index.ts` core + `upstash.ts` backends) and `src/events/` (`index.ts` - exports core *and* Redis/QStash backends — one entry point by choice), built to - `dist/tasks/{index,upstash}.js` and `dist/events/index.js`. +- **Layout:** three entry points, built to `dist/tasks/index.js`, `dist/events/index.js` and + `dist/upstash.js`. `src/tasks/index.ts` and `src/events/index.ts` must never import an + `@upstash/*` package (check the built `dist/{tasks,events}/index.js` imports); the backends live + in `src/{tasks,events}/backends/` and are exported only from `src/upstash.ts`. There is no root export. Shared: `src/shared/{auth,clients,crypto}.ts` (principal + `authOf`, lazy env clients + `nonRetryable`, WebCrypto helpers), `src/telemetry.ts`, `src/version.ts`. **WebCrypto only** (no `node:crypto` / `node:net`), so it runs on edge runtimes; `signWebhook`, @@ -705,7 +706,9 @@ implements TanStack AI's own backend contracts (see its section below) — keep `ctx.http.req`), may be async, and stamps `task.owner` / `subscription.owner`. `auth` is only what the app's route passed to `handler.fetch(request, { authInfo })` after verifying the token: the SDK never derives it from headers. `request` is unverified, for cookie/session apps; docs warn - against trusting caller-set headers. `authorize` gets `{ principal, auth, request }`. Returning `undefined` = not authenticated: the call is + against trusting caller-set headers. `authorize` gets `{ principal, auth, request }`. `PrincipalResolver` cannot return `undefined` + (typed `Principal | Promise`): it **throws** to refuse, and `resolvePrincipal` also + fails closed on a rejection or an answer without a non-empty string id. Refused = not authenticated: the call is refused (`isError` "Not authenticated" for tools, reason `not_authenticated` for events/*) — no anonymous mode, fail closed; a stored task without an owner answers nobody. Single-tenant servers pass `principal: () => "local"`. `task_status` / `task_cancel` report a non-owned task as @@ -714,10 +717,9 @@ implements TanStack AI's own backend contracts (see its section below) — keep `{ define, register, createExecuteHandler, getTask, cancelTask }`; a dispatcher gets `run` / `fail` through `attach`. Tests drive deliveries with `ManualDispatcher` / `CountingDispatcher` from `src/test-support.ts`, not with knobs on the public classes. -- **Idempotency:** `define(..., { idempotencyKey: (args) => string })` makes the task id - `sha256([owner, tool, key])`. `TaskStore.create` is **create-if-absent** and returns the existing - task when the id is taken (one Lua script on Redis), so a keyed retry is one round trip and two - racing first calls cannot both create. +- **No `idempotencyKey` (removed 2026-10-08, keep it simple; add back only if someone asks).** Task + ids are always `crypto.randomUUID()`. `TaskStore.create` stays create-if-absent in one Lua script + (atomic `HSET` + `PEXPIRE`, never overwrites); the core throws if an id is somehow taken. - **Signing keys required, no fail-open.** `QStashDispatcher`, `WorkflowDispatcher` and `QStashDelivery` verify with `receiver` or a `Receiver` from the `QSTASH_*_SIGNING_KEY` env vars, and throw on the first request when neither exists (resolved outside the verify `try`, so it is @@ -733,12 +735,14 @@ implements TanStack AI's own backend contracts (see its section below) — keep is missing): `settled` drives `onSettle` and dispatcher cancel, and the record comes back even when nothing changed, so `cancelTask` is one write. `SubscriptionStore.delete` takes `{ id, event, argsKey }` so the Redis store drops record + index in one pipeline. -- **Dispatch dedupe is per record, not per task id:** `dispatchKey(task)` = - `${taskId}-${createdAt ms}`. QStash remembers a `deduplicationId` for 10 minutes and Workflow - refuses a reused `workflowRunId`, while a keyed task id comes back once its 5-minute record - expires. A failed `dispatch` settles the fresh record `failed` ("Could not be queued") and rethrows. - The `task.finished` event id is `evt_task_${dispatchKey(task)}` for the same reason: QStash and - hosts dedupe on it. +- **Dispatch dedupe on the task id:** QStash `deduplicationId` and Workflow `workflowRunId` are the + task id, and the `task.finished` event id is `evt_task_${taskId}`. That is only safe because ids + are random: if keyed (deterministic) ids ever come back, dedupe must move to a per-record key + (taskId + createdAt), since QStash keeps dedup ids for 10 minutes and Workflow refuses a reused + run id. A failed `dispatch` settles the fresh record `failed` ("Could not be queued") and rethrows. +- **Workflow context is inferred** from the dispatcher (`TaskDispatcher`), so + `createTaskLayer({ dispatcher: new WorkflowDispatcher(...) })` types `task.run` / `task.sleep` with + no type argument; a test in `workflow.test.ts` keeps it that way. - **`RedisSubscriptionStore.find` batches:** one `ZRANGE` per argument subset (up to 256), then `MGET`s of the matches, each split into requests of at most 1,000 commands, so a popular event never sends one giant request. diff --git a/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts index 2229e93..ab7e364 100644 --- a/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts +++ b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts @@ -10,11 +10,8 @@ * message to `/api/deploy-watch/events`, which signs and POSTs it to the host and lets QStash retry. */ import { McpServer } from "@modelcontextprotocol/server"; -import { - createEventLayer, - QStashDelivery, - RedisSubscriptionStore, -} from "@upstash/mcp-toolkit/events"; +import { createEventLayer } from "@upstash/mcp-toolkit/events"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/upstash"; import { Redis } from "@upstash/redis"; import * as z from "zod"; @@ -35,7 +32,9 @@ export const events = createEventLayer({ .clone() .text() .catch(() => ""); - console.log(`[deploy-watch] POST ${String(input).slice(0, 80)} -> ${response.status} ${body.slice(0, 200)}`); + console.log( + `[deploy-watch] POST ${String(input).slice(0, 80)} -> ${response.status} ${body.slice(0, 200)}`, + ); return response; }, // The demo's own receiver runs on localhost. Never set this in production: it disables the @@ -93,7 +92,8 @@ export function createServer(): McpServer { async ({ limit }) => { const deploys = (await Redis.fromEnv().lrange(RECENT_KEY, 0, limit - 1)) ?? []; const lines = deploys.map( - (d) => `${d.status === "succeeded" ? "✅" : "❌"} ${d.service} → ${d.environment}: "${d.commit}" by ${d.author} (${d.durationSeconds}s)`, + (d) => + `${d.status === "succeeded" ? "✅" : "❌"} ${d.service} → ${d.environment}: "${d.commit}" by ${d.author} (${d.durationSeconds}s)`, ); return { content: [{ type: "text", text: lines.length ? lines.join("\n") : "No deploys yet." }], diff --git a/examples/mcp-toolkit-demo/app/lib/qstash-server.ts b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts index 75bc1f5..e62ca09 100644 --- a/examples/mcp-toolkit-demo/app/lib/qstash-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts @@ -9,7 +9,7 @@ */ import { McpServer } from "@modelcontextprotocol/server"; import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; -import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; /** Where QStash delivers. Must be reachable *from QStash*, not just from your browser. */ @@ -28,8 +28,8 @@ export const tasks = createTaskLayer({ dispatcher, defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, // Who is calling. The demo has no login, so every caller is the same user — said explicitly, - // because there is no anonymous default. A real server returns its user id from `auth`: - // principal: ({ auth }) => auth?.extra?.userId as string | undefined + // because there is no anonymous default. A real server returns its user id from `auth`, and + // throws when there is none (see "Who is calling" in the toolkit README). principal: () => "demo-user", }); @@ -41,41 +41,41 @@ const STEPS = STEP_NAMES.length; // Defined at module scope, so every instance knows the handler — including one that only ever // serves `/api/execute` and never builds an MCP server. tasks.define( - "generate_report", - { - title: "Generate report", - description: `Generates a report on a topic in ${STEPS} steps, on QStash.`, - 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}: ${STEP_NAMES[step - 1]}`); - // 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); + "generate_report", + { + title: "Generate report", + description: `Generates a report on a topic in ${STEPS} steps, on QStash.`, + 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}: ${STEP_NAMES[step - 1]}`); + // 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); + } - // A canned report: the demo is about the task lifecycle, not the writing. - const report = [ - `# ${topic}`, - `Sources reviewed: 12 (4 primary, 8 secondary).`, - `Key finding: interest in ${topic} grew steadily over the last three years.`, - `Open question: which of the competing explanations holds up under more data.`, - `Recommendation: run a small follow-up study before committing budget.`, - ].join("\n"); - return { - content: [{ type: "text", text: report }], - structuredContent: { topic, sources: 12, report }, - }; - }, - ); + // A canned report: the demo is about the task lifecycle, not the writing. + const report = [ + `# ${topic}`, + `Sources reviewed: 12 (4 primary, 8 secondary).`, + `Key finding: interest in ${topic} grew steadily over the last three years.`, + `Open question: which of the competing explanations holds up under more data.`, + `Recommendation: run a small follow-up study before committing budget.`, + ].join("\n"); + return { + content: [{ type: "text", text: report }], + structuredContent: { topic, sources: 12, report }, + }; + }, +); /** A fresh server per request: the defined task tool plus `task_status` and `task_cancel`. */ export function createServer(): McpServer { diff --git a/examples/mcp-toolkit-demo/app/lib/workflow-server.ts b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts index b8c00e8..4c800da 100644 --- a/examples/mcp-toolkit-demo/app/lib/workflow-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts @@ -5,14 +5,13 @@ * 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. + * The visible difference in code is the handler's context. The dispatcher decides it: with + * `WorkflowDispatcher` it is `TaskContext & WorkflowContext`, inferred, 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 } from "@upstash/mcp-toolkit/tasks"; -import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; -import type { WorkflowContext } from "@upstash/workflow"; +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; /** Where Workflow delivers each step. Must be reachable *from QStash*. */ @@ -21,17 +20,17 @@ export const EXECUTE_URL = `${process.env.APP_URL ?? "http://127.0.0.1:3000"}/ap export const dispatcher = new WorkflowDispatcher({ url: EXECUTE_URL }); /** - * The type argument is the whole point: it flows into `define`, so the handler below is - * typed with the engine's API and the compiler rejects a workflow handler wired to a queue. + * No type argument needed: the context type is inferred from the dispatcher and flows into + * `define`, so the compiler rejects a workflow handler wired to a queue. */ -export const tasks = createTaskLayer({ +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 }, // Who is calling. The demo has no login, so every caller is the same user — said explicitly, - // because there is no anonymous default. A real server returns its user id from `auth`: - // principal: ({ auth }) => auth?.extra?.userId as string | undefined + // because there is no anonymous default. A real server returns its user id from `auth`, and + // throws when there is none (see "Who is calling" in the toolkit README). principal: () => "demo-user", }); @@ -39,36 +38,36 @@ const STEPS = 4; // Module scope: the workflow endpoint needs the handler on every instance. tasks.define( - "generate_report", - { - title: "Generate report", - description: `Generates a report on a topic in ${STEPS} durable steps, on Upstash Workflow.`, - 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 {}; - } + "generate_report", + { + title: "Generate report", + description: `Generates a report on a topic in ${STEPS} durable steps, on Upstash Workflow.`, + 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}`); + // `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))); - } + // 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 { + content: [{ type: "text", text: `Report complete: ${topic}` }], + structuredContent: { report: `A concise report about ${topic}.` }, + }; + }, +); export function createServer(): McpServer { const server = new McpServer({ name: "mcp-toolkit-demo-workflow", version: "0.1.0" }); diff --git a/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md index fc1540d..cbe1d50 100644 --- a/packages/mcp-toolkit/README.md +++ b/packages/mcp-toolkit/README.md @@ -3,23 +3,25 @@ Durable building blocks for MCP servers on the official TypeScript SDK, backed by Upstash Redis, QStash and Workflow. -| Entry point | What it gives your server | -| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`@upstash/mcp-toolkit/tasks`](#tasks-long-running-tools) | **Long-running tools.** A tool answers at once with a task id; the model polls `task_status` for progress and the result. Works in every client today. | +| Entry point | What it gives your server | +| --- | --- | +| [`@upstash/mcp-toolkit/tasks`](#tasks-long-running-tools) | **Long-running tools.** A tool answers at once with a task id; the model polls `task_status` for progress and the result. Works in every client today. | | [`@upstash/mcp-toolkit/events`](#events-webhooks-that-wake-the-agent) | **MCP Events.** Hosts subscribe to your events and get a signed webhook when one happens, so an agent wakes up instead of polling. Works in ChatGPT today. | +| `@upstash/mcp-toolkit/upstash` | **The Upstash backends for both:** `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher`, `RedisSubscriptionStore`, `QStashDelivery`. | -`/tasks` keeps its Upstash backends behind `/tasks/upstash`; `/events` ships them in the same -entry point. Both include in-memory backends for tests. They also compose: a `task.finished` event tells an -event-capable host that a task settled, so it can skip polling. +`/tasks` and `/events` are generic: the layers, the interfaces a backend implements, and in-memory +backends for tests. Everything Upstash-specific is in `/upstash`. The two layers also compose: a +`task.finished` event tells an event-capable host that a task settled, so it can skip polling. ## Install ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash +npm install @upstash/mcp-toolkit @modelcontextprotocol/server ``` -`@upstash/workflow` is only needed for `WorkflowDispatcher`. The package uses WebCrypto only, so it -runs on Node and on edge runtimes. +`@upstash/redis`, `@upstash/qstash` and `@upstash/workflow` come with the package, so there is +nothing else to install. The MCP SDK is a peer dependency, because your server already has its own. +The package uses WebCrypto only, so it runs on Node and on edge runtimes. ## Tasks: long-running tools @@ -39,14 +41,18 @@ Codex, Cursor, OpenCode, ChatGPT — with no client capability required. See // lib/tasks.ts import { McpServer } from "@modelcontextprotocol/server"; import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; -import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - // Who is calling: your user id from the request's auth. Required — see below. - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + // Who is calling: your user id from the verified token. Required, see below. + principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; + }, }); // Module scope: every instance knows the handler, including an /api/execute instance that never @@ -54,7 +60,16 @@ export const tasks = createTaskLayer({ tasks.define( "generate_report", { description: "Generates a report on a topic.", inputSchema: z.object({ topic: z.string() }) }, - async ({ topic }) => ({ content: [{ type: "text", text: await writeReport(topic) }] }), + // `task` is the running task: report progress, and check whether it was cancelled. + async ({ topic }, task) => { + const sources = await findSources(topic); + for (const [i, source] of sources.entries()) { + if (await task.isCancelled()) return {}; + await task.update(`Reading source ${i + 1}/${sources.length}`); // the model sees this + await read(source); + } + return { content: [{ type: "text", text: await writeReport(topic) }] }; + }, ); // Per request: attach every defined task tool, plus task_status and task_cancel. @@ -98,28 +113,13 @@ That second route is deliberately not yours to write — the dispatcher owns it. `task_status` and `task_cancel` are shared by every task tool on the server. -
-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 model sees as `statusMessage` on its next `task_status`. -Cancellation is cooperative: `task_cancel` flips the record and stops a pending delivery, but -running code only stops where it checks. +The handler's second argument, `task`, has two calls, both optional: -
+- `task.update(message)` writes a progress line. The model sees it as `statusMessage` on its next + `task_status`. +- `task.isCancelled()` is true once `task_cancel` was called (or the record expired). + Cancellation is cooperative: `task_cancel` flips the record and stops a pending delivery, but + running code only stops where it checks. ### Who is calling: `principal` @@ -143,16 +143,18 @@ It receives `{ auth, request }`: } ``` - `extra` is free-form: put the user id there in `verifyToken`, and read it back with - `principal: ({ auth }) => auth?.extra?.userId as string | undefined`. Because the token was - already checked, this is the path to prefer. + `extra` is free-form: put the user id there in `verifyToken`, and read it back in `principal`, + as in the snippet above. Because the token was already checked, this is the path to prefer. - **`request`** is the raw HTTP request, for apps that authenticate with a cookie or session instead of an `AuthInfo`. It is unverified, so check the session yourself (`principal` may be async), and never trust a header like `x-user-id` that the caller can set. -There is no anonymous mode. When `principal` returns `undefined`, the call is refused with "Not -authenticated". A server with no users of its own (a local tool, a demo) says so explicitly: +`principal` must return an id; its type does not allow `undefined`. When it cannot identify the +caller, it **throws**, and the call is refused with "Not authenticated". The layer fails closed +even if the types are bypassed: a throw, a rejected promise, or an answer without a non-empty +string id (an `as string` that lied, plain JavaScript) is a refusal too. There is no anonymous +mode. A server with no users of its own (a local tool, a demo) says so explicitly: ```ts principal: () => "local", @@ -161,27 +163,6 @@ principal: () => "local", Key on the _user_, not `auth.clientId`: the client id identifies the OAuth app, which is often one id shared by every user of a host like ChatGPT. -### Retries without duplicates: `idempotencyKey` - -Agents retry tool calls, especially ones that seemed to time out. Give a task tool an -`idempotencyKey`, and a second call from the same caller with the same key returns the existing -task instead of starting another: - -```ts -tasks.define( - "generate_report", - { - description: "Generates a report on a topic.", - inputSchema: z.object({ topic: z.string() }), - idempotencyKey: ({ topic }) => topic, - }, - handler, -); -``` - -Keys are scoped by caller and tool, and last as long as the task is retained (`ttlMs`). Return -`undefined` to opt a call out. - ### What the model sees ```jsonc @@ -218,38 +199,50 @@ A queue delivery is a single serverless invocation: exceed your platform's funct 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.** Three things change: +**Start on QStash. Move to Workflow when the work outgrows a function.** A Workflow task, end to +end: ```ts -import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; -import type { WorkflowContext } from "@upstash/workflow"; +// lib/tasks.ts +import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/upstash"; +import * as z from "zod"; -const tasks = createTaskLayer({ +export const tasks = createTaskLayer({ store: new RedisTaskStore(), + // The dispatcher decides what `task` is: with Workflow it also has `run`, `sleep` and `call`. dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => auth?.extra?.userId as string | undefined, - // The record's TTL runs from creation and is never extended. Once it passes, the record is gone - // and `isCancelled()` returns true, so give long work a longer one than the 5-minute default. + principal, // the same function as in the QStash example + // A TTL longer than the work. It runs from creation and is never extended: once it passes, + // the record is gone and `isCancelled()` returns true. The default is 5 minutes. defaults: { ttlMs: 60 * 60 * 1000 }, }); -``` -1. The `createTaskLayer` type argument. -2. The work wrapped in `task.run(...)` steps. Each step is still one invocation, so keep each step - within your function limit. -3. A `ttlMs` longer than the work. - -The type argument flows into `define`, 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)) }] }; -}; +tasks.define( + "migrate_workspace", + { + description: "Copies a workspace's documents to the new storage, one batch at a time.", + inputSchema: z.object({ workspaceId: z.string() }), + }, + async ({ workspaceId }, task) => { + // The work in steps. Each step is its own invocation, replayed from the journal once done. + const batches = await task.run("plan", () => listBatches(workspaceId)); + for (const [i, batch] of batches.entries()) { + if (await task.isCancelled()) return {}; + await task.update(`Copying batch ${i + 1}/${batches.length}`); + await task.run(`copy-${i}`, () => copyBatch(batch)); + } + await task.sleep("settle", 30); // waits without holding a function open + const report = await task.run("verify", () => verifyCopy(workspaceId)); + return { content: [{ type: "text", text: report }] }; + }, +); ``` +The routes are the same as with QStash: `export const POST = tasks.createExecuteHandler()` now +returns Workflow's own `serve()` handler. Each step is still one invocation, so keep every step +within your function limit; the task as a whole has none. +
Writing a workflow handler: what goes inside a step @@ -272,7 +265,7 @@ step body executed exactly once.** | | Owns | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`@upstash/mcp-toolkit/tasks`** | The tools: creating the record before replying, `task_status` / `task_cancel`, ownership and idempotency, the redelivery guard, settling `completed`/`cancelled` | +| **`@upstash/mcp-toolkit/tasks`** | The tools: creating the record before replying, `task_status` / `task_cancel`, ownership, 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 | @@ -386,7 +379,8 @@ subscription ids, expiry and refresh, Standard Webhooks signing, and durable ret ```ts // lib/events.ts -import { createEventLayer, QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; +import { createEventLayer } from "@upstash/mcp-toolkit/events"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; export const events = createEventLayer({ @@ -394,7 +388,7 @@ export const events = createEventLayer({ delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), // `secretKey` defaults to MCP_EVENTS_SECRET_KEY, which encrypts the hosts' signing secrets at // rest. There is no built-in default: generate one with `openssl rand -base64 32`. - principal: ({ auth }) => auth?.extra?.userId as string | undefined, // required, as for tasks + principal, // required: the same function as for tasks }); export const commentCreated = events.define("comment.created", { @@ -486,7 +480,7 @@ arguments say **what**, and `authorize` says **who may**. them `personal: true`, and every `emit` must say who with `to` (the type requires it, and so does a runtime check). A subscription with no arguments then only hears about its own user's events. `to` also works on ordinary events, to narrow one emit. -- **No anonymous subscriptions.** When `principal` returns `undefined`, subscribe and unsubscribe +- **No anonymous subscriptions.** When `principal` throws, subscribe and unsubscribe are refused with reason `not_authenticated`, before any challenge is sent. Only the subscriber can unsubscribe. @@ -562,8 +556,8 @@ the guarantees are only as good as `principal`: it must return the user (the tok 2. **Every read checks it.** `task_status` and `task_cancel` run `principal` again, load the task, and compare its `owner` to the caller. Anything else gets the same answer as an id that never existed ("Unknown task"), so another user's id cannot even be confirmed to exist. -3. **Ids don't help.** Unkeyed ids are random UUIDs. Keyed ids hash the owner in, so the same - `idempotencyKey` gives each user their own task and never someone else's. +3. **Ids don't help.** Task ids are random UUIDs, and knowing one gets you nothing without being + its owner. 4. **There is no other way in.** No tool lists tasks. The execute endpoint only accepts deliveries signed by QStash, and it runs the handler; it never returns a task to the caller. 5. **`task.finished` follows the same owner.** It is a personal event, emitted `to` the task's @@ -649,18 +643,15 @@ write these keys can change who owns a task or where an event goes. matches it; another caller's id reads as unknown. That is why `principal` must return the user (the token's subject), not `auth.clientId`. - The caller's token, `AuthInfo` and request are **never stored**. -- The owner is not a separate part of the key; it is folded into the task id. A task without an - `idempotencyKey` gets a random id (`crypto.randomUUID()`). A keyed task's id is the first 32 hex - characters of `sha256(JSON.stringify([owner, tool, key]))`, so alice and bob calling - `generate_report` with the same key get different ids, and so different records. Either way the - stored `owner` field is checked again on every read. +- The owner is not part of the key. The task id is a random UUID (`crypto.randomUUID()`), so two + tasks never share a record, and the stored `owner` field is what every read checks. **Correctness** - **Durable before the reply.** The record is written before the tool answers with its id, because - the model may poll from another instance right away. Creation is one Lua script that writes only - if the key is absent, so two racing retries with the same key cannot both create a task, and a - retry gets the existing task back. + the model may poll from another instance right away. Creation is one Lua script that writes the + hash and its expiry together, and only if the key is absent, so a record never exists without a + TTL and is never overwritten. - **First terminal write wins.** Progress updates and terminal transitions go through one guarded Lua script that refuses to touch a task that is already `completed`, `failed` or `cancelled`. A completion that lands after a cancel cannot overwrite it, and a late progress line cannot @@ -723,9 +714,8 @@ so an emit reads only the subscriptions its arguments can match. - **QStash messages.** A task delivery carries only `{ taskId }`. An event delivery carries the subscription id and the full event envelope, including its payload, so the payload sits in QStash (and its DLQ, if every retry fails) until it is delivered. -- **Deduplication keys.** Task dispatches dedupe on `taskId` plus creation time, and event deliveries - on event id plus subscription id. Re-creating an expired keyed task gets a new key, so QStash's - 10-minute dedup window never swallows it. +- **Deduplication keys.** Task dispatches dedupe on the task id (and a Workflow run is named after + it), and event deliveries on event id plus subscription id. - **Never stored anywhere by the toolkit:** the caller's token or `AuthInfo`, the raw request, the plaintext webhook secret, and `MCP_EVENTS_SECRET_KEY`. @@ -746,7 +736,7 @@ interface TaskStore { } interface TaskDispatcher { - /** Dedupe on `dispatchKey(task)`: a keyed task id comes back once its record expires. */ + /** Idempotent per task id. */ dispatch(task: Task): Promise; cancel(dispatchId: string): Promise; attach?(endpoints: TaskEndpoints): void; @@ -769,14 +759,14 @@ neither is durable, which is exactly the failure this package is about. | | | | ------------------------- | ----------------------------------------------------------------------------------------------------------- | | `store`, `dispatcher` | Required. | -| `principal` | Required. `({ auth, request }) => string \| undefined`, may be async — the caller's id; `undefined` refuses the call. | +| `principal` | Required. `({ auth, request }) => string`, may be async — the caller's id; throw to refuse the call. | | `defaults.ttlMs` | Retention window, `null` for unlimited. Default 5 min. | | `defaults.pollIntervalMs` | Poll interval suggested to the model. Default 2s. | | `toolNames` | Rename `task_status` / `task_cancel`, e.g. to namespace them. | | `onSettle` | `(task) => void` — called once when a task completes, fails or is cancelled. Wire `taskFinishedEvent` here. | **`define(name, config, handler)` config** — `description`, `inputSchema`, plus optional `title`, `ttlMs`, -`pollIntervalMs`, `queuedMessage`, `completedMessage`, `idempotencyKey`. +`pollIntervalMs`, `queuedMessage`, `completedMessage`. **`RedisTaskStore`** — `redis` (defaults to `Redis.fromEnv()`; `automaticDeserialization: false` is not supported), `prefix`, `enableTelemetry`. @@ -809,13 +799,13 @@ itself, or with `UPSTASH_DISABLE_TELEMETRY`. | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `createTaskLayer(options)` | `{ define, register, createExecuteHandler, getTask, cancelTask }` | | `TaskStore`, `TaskDispatcher`, `TaskContext` | The two seams, and what a handler is handed | -| `TaskEndpoints`, `TaskJournal`, `dispatchKey` | What a dispatcher calls back into, how it journals this package's writes, and the key it dedupes on | +| `TaskEndpoints`, `TaskJournal` | What a dispatcher calls back into, and how it journals this package's writes | | `SettleResult`, `PrincipalResolver` | What `settle` returns, and the type of `principal` | | `Task`, `WireTask`, `TaskStatus`, `TaskError` | The record, and the subset the model sees | | `isTerminal`, `TERMINAL_STATUSES`, `UnknownTaskError` | Status helpers and the store's error type | | `DEFAULT_TOOL_NAMES`, `Caller`, `CallerAuth` | `{ status: "task_status", cancel: "task_cancel" }`, and what `principal` receives | | `MemoryTaskStore`, `InlineTaskDispatcher` | Non-durable backends for tests | -| `@upstash/mcp-toolkit/tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher` | +| `@upstash/mcp-toolkit/upstash` | `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher`, `WorkflowContext` (type) |
@@ -855,7 +845,7 @@ most 1,000 commands. | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `store`, `delivery` | Required. | | `secretKey` | Encrypts stored signing secrets. Defaults to `MCP_EVENTS_SECRET_KEY`; required, no built-in default. | -| `principal` | Required. `({ auth, request }) => id \| { id, context } \| undefined`, may be async — the subscriber; `undefined` refuses. | +| `principal` | Required. `({ auth, request }) => id \| { id, context }`, may be async — the subscriber; throw to refuse. | | `defaults.ttlMs` / `defaults.maxTtlMs` | Granted lifetime when none is asked for (7 days), and the cap (30 days). | | `allowInsecureCallbacks` | Accept `http://` and private hosts. Local development only. | | `timeoutMs` | Per-POST timeout. Default 10s. | @@ -883,7 +873,7 @@ most 1,000 commands. | `EventPayloadTooLargeError`, `CALLBACK_ENDPOINT_ERROR`, `MAX_PAYLOAD_BYTES` | Limits and errors | | `AuthorizeCaller`, `Recipients`, `Principal`, `EmitOptions` | What `authorize` is told, what `to` takes, what `principal` returns | | `MemorySubscriptionStore`, `InlineDelivery` | Non-durable backends for tests | -| `RedisSubscriptionStore`, `QStashDelivery` | The Upstash backends (need `@upstash/redis` and `@upstash/qstash`) | +| `@upstash/mcp-toolkit/upstash` | `RedisSubscriptionStore`, `QStashDelivery` |
@@ -919,8 +909,7 @@ A dispatcher that runs work in-process — `InlineTaskDispatcher` — has no end Won't the model give up polling? Sometimes, which is why every response says what to do next in words — "call `task_status` in -about 2s" — and `task_status` tells the model not to start the task again. If it does start it -again, `idempotencyKey` turns the retry into a read of the existing task. Nothing is lost if the +about 2s" — and `task_status` tells the model not to start the task again. Nothing is lost if the model stops polling: the work finishes anyway, and the result stays readable until the task's TTL. @@ -944,8 +933,7 @@ id and the failed response attached. The message is in the QStash DLQ, not lost. Is the task id a secret? No. Every task is scoped to the `principal` that started it, so an id is useless to anyone but its -owner. Ids are also random (~122 bits for `randomUUID`), and idempotent task ids are a hash of -caller, tool and key, so they are not guessable either. +owner. Ids are also random (~122 bits for `randomUUID`), so they are not guessable either. diff --git a/packages/mcp-toolkit/package.json b/packages/mcp-toolkit/package.json index 4fcff65..00f5537 100644 --- a/packages/mcp-toolkit/package.json +++ b/packages/mcp-toolkit/package.json @@ -18,13 +18,13 @@ "types": "./dist/tasks/index.d.ts", "import": "./dist/tasks/index.js" }, - "./tasks/upstash": { - "types": "./dist/tasks/upstash.d.ts", - "import": "./dist/tasks/upstash.js" - }, "./events": { "types": "./dist/events/index.d.ts", "import": "./dist/events/index.js" + }, + "./upstash": { + "types": "./dist/upstash.d.ts", + "import": "./dist/upstash.js" } }, "files": [ @@ -52,31 +52,17 @@ "agent" ], "dependencies": { + "@upstash/qstash": "^2.11.3", + "@upstash/redis": "^1.38.0", + "@upstash/workflow": "^1.3.3", "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 - } + "@modelcontextprotocol/server": "^2.0.0" }, "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" + "dotenv": "^16.4.5" } } diff --git a/packages/mcp-toolkit/src/events/core.test.ts b/packages/mcp-toolkit/src/events/core.test.ts index d994093..65c551f 100644 --- a/packages/mcp-toolkit/src/events/core.test.ts +++ b/packages/mcp-toolkit/src/events/core.test.ts @@ -14,6 +14,7 @@ import { InlineDelivery, MemorySubscriptionStore } from "./backends/memory.js"; import { taskFinishedEvent } from "./tasks.js"; import { callbackUrlProblem, verifyWebhook } from "./webhooks.js"; import type { EventEnvelope, SendOutcome } from "./types.js"; +import { userIdOf } from "../test-support.js"; const PROTOCOL_VERSION = "2026-07-28"; const newSecret = () => `whsec_${randomBytes(32).toString("base64")}`; @@ -97,7 +98,7 @@ function setup(options: Partial & { respond?: Respond } = {}) store, delivery: new InlineDelivery({ onOutcome: (_job, outcome) => outcomes.push(outcome) }), secretKey: "test-key", - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal: userIdOf, fetch: receiver.fetch, ...layerOptions, }); @@ -442,9 +443,9 @@ describe("access across users", () => { it("hands authorize the principal's context at subscribe and again at delivery", async () => { const seen: { phase: string; principal: string; org: unknown; hasAuth: boolean }[] = []; const { events, subscribe, receiver } = setup({ - principal: ({ auth }) => { - const userId = auth?.extra?.userId as string | undefined; - return userId && { id: userId, context: { org: `org-of-${userId}` } }; + principal: (caller) => { + const userId = userIdOf(caller); + return { id: userId, context: { org: `org-of-${userId}` } }; }, }); const memo = events.define("memo.posted", { @@ -571,7 +572,10 @@ describe("principal", () => { it("can identify the caller from the raw request, e.g. a session cookie", async () => { // No AuthInfo at all: an app that authenticates with its own session reads the request. const { subscribe, store } = setup({ - principal: async ({ request }) => (request ? "session-user" : undefined), + principal: async ({ request }) => { + if (!request) throw new Error("no session"); + return "session-user"; + }, }); const sub = await subscribe({ documentId: "d" }, { user: null }); expect(sub.error).toBeUndefined(); @@ -596,7 +600,7 @@ describe("task.finished", () => { const tasks = createTaskLayer({ store: new MemoryTaskStore(), dispatcher: new InlineTaskDispatcher(), - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal: userIdOf, onSettle: taskFinished.onSettle, }); tasks.define("slow", { description: "slow", inputSchema: z.object({}) }, async () => ({ @@ -626,48 +630,16 @@ describe("task.finished", () => { expect(deliveries[0]?.url).toBe(alice.url); expect(deliveries[0]?.body).toMatchObject({ name: "task.finished", - eventId: expect.stringMatching(new RegExp(`^evt_task_${taskId}-\\d+$`)), + eventId: `evt_task_${taskId}`, data: { taskId, status: "completed", result: { content: [{ type: "text", text: "done" }] } }, }); - // The event id is per task record, not per task id: a keyed task re-created after its record - // expired reuses the id, and must not be dropped as a duplicate of the first one's event. - expect(deliveries[0]?.body.eventId).not.toBe(`evt_task_${taskId}`); - // A cancel after completion settles nothing, so nothing fires. await tasks.cancelTask(taskId); expect(receiver.deliveries()).toHaveLength(1); }); }); -describe("task.finished event ids", () => { - it("differ for a keyed task re-created after its record expired", async () => { - const { events } = setup(); - const taskFinished = taskFinishedEvent(events); - const ids: (string | undefined)[] = []; - (taskFinished.event as { emit: unknown }).emit = async ( - _payload: unknown, - options: { eventId?: string }, - ) => { - ids.push(options.eventId); - return { eventId: options.eventId ?? "", matched: 0 }; - }; - const record = { - taskId: "keyed", - status: "completed" as const, - lastUpdatedAt: "2026-10-08T10:00:01.000Z", - ttlMs: 300_000, - name: "slow", - args: {}, - owner: "alice", - }; - await taskFinished.onSettle({ ...record, createdAt: "2026-10-08T10:00:00.000Z" }); - await taskFinished.onSettle({ ...record, createdAt: "2026-10-08T10:06:00.000Z" }); - expect(ids).toHaveLength(2); - expect(ids[0]).not.toBe(ids[1]); - }); -}); - describe("helpers", () => { it("enumerates every subset of the emitted arguments", () => { expect(matchKeys({ a: 1, b: 2 }).sort()).toEqual( diff --git a/packages/mcp-toolkit/src/events/core.ts b/packages/mcp-toolkit/src/events/core.ts index bcac7b9..68d5556 100644 --- a/packages/mcp-toolkit/src/events/core.ts +++ b/packages/mcp-toolkit/src/events/core.ts @@ -50,8 +50,8 @@ export type EventLayerOptions = { */ secretKey?: string; /** - * Who is calling, usually your user id: `({ auth }) => auth?.extra?.userId`. Required; - * `undefined` refuses the subscribe. Return `{ id, context }` to store non-secret context (an + * Who is calling, usually your user id from `auth`. Required, and it must return an id: throw + * when it can't, and the subscribe is refused as not authenticated. Return `{ id, context }` to store non-secret context (an * org id) that `authorize` gets back before each delivery. Never put the token in it. */ principal: PrincipalResolver; diff --git a/packages/mcp-toolkit/src/events/index.ts b/packages/mcp-toolkit/src/events/index.ts index fb0e813..64a0c14 100644 --- a/packages/mcp-toolkit/src/events/index.ts +++ b/packages/mcp-toolkit/src/events/index.ts @@ -1,7 +1,7 @@ /** * `@upstash/mcp-toolkit/events`: MCP Events (webhook delivery) for servers on the official - * TypeScript SDK, with the Upstash backends and in-memory ones for tests. Needs `@upstash/redis` - * and `@upstash/qstash` installed. + * TypeScript SDK, with in-memory backends for tests. The Upstash backends are in + * `@upstash/mcp-toolkit/upstash`. */ export { createEventLayer, @@ -48,13 +48,6 @@ export { } from "./webhooks.js"; export { taskFinishedEvent, type TaskFinishedPayload } from "./tasks.js"; export { InlineDelivery, MemorySubscriptionStore } from "./backends/memory.js"; -export { - RedisSubscriptionStore, - QStashDelivery, - DEFAULT_EVENTS_PREFIX, - type RedisSubscriptionStoreConfig, - type QStashDeliveryConfig, -} from "./backends/qstash.js"; export { SDK_TELEMETRY } from "../telemetry.js"; export { VERSION } from "../version.js"; diff --git a/packages/mcp-toolkit/src/events/tasks.ts b/packages/mcp-toolkit/src/events/tasks.ts index 07e4344..f368ea4 100644 --- a/packages/mcp-toolkit/src/events/tasks.ts +++ b/packages/mcp-toolkit/src/events/tasks.ts @@ -9,7 +9,7 @@ * A host subscribes with no arguments for every task its user starts, or with a `taskId`. */ import * as z from "zod"; -import { dispatchKey, type Task } from "../tasks/types.js"; +import type { Task } from "../tasks/types.js"; import { EventPayloadTooLargeError, type EventLayer } from "./core.js"; const taskFinishedInput = z.object({ @@ -62,9 +62,8 @@ export function taskFinishedEvent( const emitOptions = { to: task.owner, args: { taskId: task.taskId }, - // Stable per task record, so a repeated settle hook cannot deliver twice, while a keyed task - // re-created after expiry (same id, new createdAt) is not mistaken for a duplicate. - eventId: `evt_task_${dispatchKey(task)}`, + // Stable per task, so a repeated settle hook cannot deliver twice. + eventId: `evt_task_${task.taskId}`, }; try { await event.emit(payload, emitOptions); diff --git a/packages/mcp-toolkit/src/shared/auth.ts b/packages/mcp-toolkit/src/shared/auth.ts index 1f1cdf6..71d755d 100644 --- a/packages/mcp-toolkit/src/shared/auth.ts +++ b/packages/mcp-toolkit/src/shared/auth.ts @@ -30,12 +30,11 @@ export type Caller = { export type Principal = string | { id: string; context?: Record }; /** - * Maps a call to a stable caller id, usually your user id. `undefined` means "not - * authenticated", and the call is refused. May be async, e.g. to look up a session. + * Maps a call to a stable caller id, usually your user id. It must return one: when it cannot + * identify the caller, throw, and the call is refused as not authenticated. May be async, e.g. to + * look up a session. */ -export type PrincipalResolver = ( - caller: Caller, -) => Principal | undefined | Promise; +export type PrincipalResolver = (caller: Caller) => Principal | Promise; /** The most JSON a principal's context may take, since it is stored with every subscription. */ export const MAX_CONTEXT_BYTES = 4096; @@ -46,26 +45,36 @@ export function callerOf(context: unknown): Caller { return { auth: http?.authInfo, request: http?.req }; } -/** Runs the resolver and normalizes its answer to `{ id, context }`, or `undefined`. */ +/** + * Runs the resolver and normalizes its answer to `{ id, context }`. Fails closed: a throw, or an + * answer without a non-empty string id (from an `as string` that lied, or plain JavaScript), + * gives `undefined`, which the layers refuse as not authenticated. + */ export async function resolvePrincipal( principal: PrincipalResolver, caller: Caller, ): Promise<{ id: string; context: Record } | undefined> { - const result = await principal(caller); - if (result === undefined || result === null) return undefined; - if (typeof result === "string") return { id: result, context: {} }; - const context = result.context ?? {}; + let result: unknown; + try { + result = await principal(caller); + } catch { + return undefined; + } + const id = typeof result === "string" ? result : (result as { id?: unknown } | null)?.id; + if (typeof id !== "string" || id === "") return undefined; + if (typeof result === "string") return { id, context: {} }; + const context = (result as { context?: Record }).context ?? {}; if (new TextEncoder().encode(JSON.stringify(context)).length > MAX_CONTEXT_BYTES) { throw new Error(`principal context must be at most ${MAX_CONTEXT_BYTES} bytes of JSON`); } - return { id: result.id, context }; + return { id, context }; } /** Throws at construction when a layer is built without a principal. */ export function requirePrincipal(principal: unknown, layer: string): PrincipalResolver { if (typeof principal !== "function") { throw new Error( - `${layer} needs a \`principal\`: ({ auth }) => your user id. A server with no users of its ` + + `${layer} needs a \`principal\` that returns your user id (and throws when it can't). A server with no users of its ` + 'own passes `principal: () => "local"`.', ); } diff --git a/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts b/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts index f1f4dde..d214cf3 100644 --- a/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts +++ b/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; import { QStashDispatcher, RedisTaskStore } from "./qstash.js"; -import { dispatchKey, UnknownTaskError, type Task, type TaskError } from "../types.js"; +import { UnknownTaskError, type Task, type TaskError } from "../types.js"; import { cleanupKeys, hasRedisCreds, testRedis, uniquePrefix } from "../../test-support.js"; const makeTask = (overrides: Partial = {}): Task => { @@ -395,17 +395,9 @@ describe("QStashDispatcher.dispatch", () => { } as unknown as ConstructorParameters[0]["qstash"]; const dispatcher = new QStashDispatcher({ url: "https://example.com/api/execute", qstash }); - // A keyed task re-created after its record expired: same id, new createdAt. - const first = makeTask({ taskId: "t", createdAt: "2026-10-08T10:00:00.000Z" }); - const second = makeTask({ taskId: "t", createdAt: "2026-10-08T10:06:00.000Z" }); - expect(await dispatcher.dispatch(first)).toBe("msg_1"); - await dispatcher.dispatch(second); - - expect(published.map((p) => p.deduplicationId)).toEqual([ - dispatchKey(first), - dispatchKey(second), - ]); - expect(published[0]?.deduplicationId).not.toBe(published[1]?.deduplicationId); + expect(await dispatcher.dispatch(makeTask({ taskId: "t" }))).toBe("msg_1"); + // Deduplicated on the task id, so a double dispatch of one task is delivered once. + expect(published[0]?.deduplicationId).toBe("t"); expect(published[0]?.body).toEqual({ taskId: "t" }); }); }); diff --git a/packages/mcp-toolkit/src/tasks/backends/qstash.ts b/packages/mcp-toolkit/src/tasks/backends/qstash.ts index f4e2b3f..e2566cb 100644 --- a/packages/mcp-toolkit/src/tasks/backends/qstash.ts +++ b/packages/mcp-toolkit/src/tasks/backends/qstash.ts @@ -10,7 +10,6 @@ import { } from "../../shared/clients.js"; import { fromBase64, fromUtf8 } from "../../shared/crypto.js"; import { - dispatchKey, UnknownTaskError, type SettleResult, type Task, @@ -186,8 +185,7 @@ export class QStashDispatcher implements TaskDispatcher { headers: this.config.headers, // Comes back to the same route once retries are exhausted, and settles the task `failed`. failureCallback: this.config.url, - // Per record, not per id: QStash remembers ids for 10 minutes, longer than a keyed task's TTL. - deduplicationId: dispatchKey(task), + deduplicationId: task.taskId, }); return Array.isArray(message) ? message[0]?.messageId : message.messageId; } diff --git a/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts b/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts index 2927143..e4a951a 100644 --- a/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts +++ b/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts @@ -8,10 +8,11 @@ import { Client as QStashClient } from "@upstash/qstash"; import { WorkflowContext } from "@upstash/workflow"; import { describe, expect, it } from "vitest"; +import * as z from "zod"; import { insideStep, WorkflowDispatcher } from "./workflow.js"; import { createTaskLayer } from "../core.js"; import { MemoryTaskStore } from "./memory.js"; -import { dispatchKey, type Task, type TaskContext } from "../types.js"; +import type { Task, TaskContext } from "../types.js"; import { ManualDispatcher } from "../../test-support.js"; const task = (overrides: Partial = {}): Task => ({ @@ -49,6 +50,25 @@ function stubClient() { } describe("WorkflowDispatcher", () => { + it("gives handlers the Workflow API without a type argument", () => { + const tasks = createTaskLayer({ + store: new MemoryTaskStore(), + dispatcher: new WorkflowDispatcher({ url: "https://example.com/api/workflow" }), + principal: () => "local", + }); + // Compiles only if `task` is inferred as TaskContext & WorkflowContext. + tasks.define( + "t", + { description: "d", inputSchema: z.object({ n: z.number() }) }, + async ({ n }, task) => { + const doubled: number = await task.run("double", async () => n * 2); + await task.sleep("wait", 1); + await task.update(`got ${doubled}`); + return {}; + }, + ); + }); + it("triggers a run named after the task record, so a double dispatch is deduplicated", async () => { const { client, triggered } = stubClient(); const dispatcher = new WorkflowDispatcher({ url: "https://example.com/api/workflow", client }); @@ -58,11 +78,8 @@ describe("WorkflowDispatcher", () => { expect(triggered).toHaveLength(1); expect(triggered[0]?.url).toBe("https://example.com/api/workflow"); expect(triggered[0]?.body).toEqual({ taskId: "task-1" }); - // Per record: Workflow refuses a used run id, and a keyed task id comes back after expiry. - expect(triggered[0]?.workflowRunId).toBe(dispatchKey(task())); - expect(dispatchId).toBe(dispatchKey(task())); - await dispatcher.dispatch(task({ createdAt: "2026-10-08T10:06:00.000Z" })); - expect(triggered[1]?.workflowRunId).not.toBe(triggered[0]?.workflowRunId); + expect(triggered[0]?.workflowRunId).toBe("task-1"); + expect(dispatchId).toBe("task-1"); }); it("cancels the run itself, not just the task record", async () => { diff --git a/packages/mcp-toolkit/src/tasks/backends/workflow.ts b/packages/mcp-toolkit/src/tasks/backends/workflow.ts index 2626a7e..70f29d3 100644 --- a/packages/mcp-toolkit/src/tasks/backends/workflow.ts +++ b/packages/mcp-toolkit/src/tasks/backends/workflow.ts @@ -6,13 +6,7 @@ import type { Client as QStashClient, Receiver } from "@upstash/qstash"; import { Client as WorkflowClient, serve, type WorkflowContext } from "@upstash/workflow"; import { env, lazy, requireEnv, resolveQStash, resolveReceiver } from "../../shared/clients.js"; import { addQStashTelemetry } from "../../telemetry.js"; -import { - dispatchKey, - type Task, - type TaskDispatcher, - type TaskEndpoints, - type TaskJournal, -} from "../types.js"; +import { type Task, type TaskDispatcher, type TaskEndpoints, type TaskJournal } from "../types.js"; /** JSON-RPC internal error. */ const INTERNAL_ERROR = -32603; @@ -75,8 +69,8 @@ export class WorkflowDispatcher implements TaskDispatcher } }) => - auth?.extra?.userId as string | undefined; +const principal = userIdOf; /** Builds a server with one task tool backed by `handler`. */ async function harness( @@ -311,7 +310,7 @@ describe("createTaskLayer over MCP", () => { principal: async ({ auth, request }) => { seen.push({ userId: auth?.extra?.userId, url: request?.url }); await sleep(1); - return auth?.extra?.userId as string | undefined; + return userIdOf({ auth }); }, }); const taskId = await live.start("x", "alice"); @@ -359,6 +358,26 @@ describe("createTaskLayer over MCP", () => { await live.dispatcher.drain(); }); + it("refuses when principal throws, or returns no usable id at runtime", async () => { + for (const answer of [undefined, "", null, { id: "" }, 42]) { + live = await harness(steppedHandler(1, 1), { + // Plain JavaScript, or an `as string` that lied: the layer still fails closed. + principal: (() => answer) as never, + }); + const start = await live.call("generate_report", { topic: "x" }, "alice"); + expect(text(start)).toMatch(/Not authenticated/); + } + live = await harness(steppedHandler(1, 1), { + principal: async () => { + throw new Error("session store is down"); + }, + }); + expect(text(await live.call("generate_report", { topic: "x" }, "alice"))).toMatch( + /Not authenticated/, + ); + expect(live.dispatcher.dispatched).toBe(0); + }); + it("never answers for a stored task without an owner", async () => { live = await harness(steppedHandler(1, 1)); const taskId = await live.start("x", "alice"); @@ -380,34 +399,10 @@ describe("createTaskLayer over MCP", () => { }); }); - describe("idempotency", () => { - it("returns the same task for a retried call with the same key", async () => { - live = await harness(steppedHandler(4, 30), {}, { idempotencyKey: (args) => args.topic }); - const first = await live.call("generate_report", { topic: "x" }); - const retry = await live.call("generate_report", { topic: "x" }); - - expect(retry.structuredContent?.taskId).toBe(first.structuredContent?.taskId); - expect(text(retry)).toMatch(/already exists/); - expect(live.dispatcher.dispatched).toBe(1); - - const other = await live.call("generate_report", { topic: "y" }); - expect(other.structuredContent?.taskId).not.toBe(first.structuredContent?.taskId); - await live.dispatcher.drain(); - }); - - it("scopes keys by owner, so two callers never share a task", async () => { - live = await harness(steppedHandler(1, 1), {}, { idempotencyKey: (args) => args.topic }); - const alice = await live.start("x", "alice"); - const bob = await live.start("x", "bob"); - expect(alice).not.toBe(bob); - await live.dispatcher.drain(); - }); - - it("starts a fresh task on every call without a key", async () => { - live = await harness(steppedHandler(1, 1)); - expect(await live.start("x")).not.toBe(await live.start("x")); - await live.dispatcher.drain(); - }); + it("starts a fresh task, with a random id, on every call", async () => { + live = await harness(steppedHandler(1, 1)); + expect(await live.start("x")).not.toBe(await live.start("x")); + await live.dispatcher.drain(); }); describe("at-least-once delivery", () => { @@ -485,22 +480,6 @@ describe("createTaskLayer over MCP", () => { expect(settled).toHaveLength(1); expect(settled[0]).toMatchObject({ status: "failed", statusMessage: "Could not be queued" }); }); - - it("dispatches a re-created keyed task under a new dispatch key", async () => { - const manual = new ManualDispatcher(); - live = await harness( - steppedHandler(1, 1), - { dispatcher: manual as never }, - { idempotencyKey: (args) => args.topic, ttlMs: 30 }, - ); - const first = await live.start("x"); - await sleep(60); // the record expires; the same key now makes a new record with the same id - const second = await live.start("x"); - expect(second).toBe(first); - expect(manual.dispatched).toHaveLength(2); - const [a, b] = manual.dispatched.map(dispatchKey); - expect(a).not.toBe(b); - }); }); it("calls onSettle once and cancels the dispatch once, however often a task is cancelled", async () => { diff --git a/packages/mcp-toolkit/src/tasks/core.ts b/packages/mcp-toolkit/src/tasks/core.ts index 0d05853..249b030 100644 --- a/packages/mcp-toolkit/src/tasks/core.ts +++ b/packages/mcp-toolkit/src/tasks/core.ts @@ -13,7 +13,6 @@ import { type CallerAuth, type PrincipalResolver, } from "../shared/auth.js"; -import { sha256Hex } from "../shared/crypto.js"; import { isTerminal, UnknownTaskError, @@ -46,8 +45,8 @@ export type TaskLayerOptions = { pollIntervalMs?: number; }; /** - * Who is calling, usually your user id: `({ auth }) => auth?.extra?.userId`. Required. Every task - * is owned by its caller, and `undefined` refuses the call. `auth.clientId` is the OAuth app + * Who is calling, usually your user id from `auth`. Required, and it must return an id: throw + * when it can't, and the call is refused as not authenticated. Every task is owned by its caller. `auth.clientId` is the OAuth app * (shared by every ChatGPT user), so it is the wrong key. A server with no users of its own * passes `() => "local"`. */ @@ -61,7 +60,7 @@ export type TaskLayerOptions = { onSettle?: (task: Task) => void | Promise; }; -export type TaskToolConfig> = { +export type TaskToolConfig = { title?: string; /** What the tool does. The layer appends a sentence about polling `task_status`. */ description: string; @@ -74,11 +73,6 @@ export type TaskToolConfig string | undefined; }; type InferArgs = Schema extends { @@ -194,12 +188,7 @@ export function createTaskLayer( const callback = async (args: unknown, context: unknown): Promise> => { const owner = await callerOf(context); if (owner === undefined) return notAuthenticatedResult(); - const key = config.idempotencyKey?.(args); - // A keyed call gets a deterministic id scoped to its caller, so a retry finds the first task. - const taskId = - key === undefined - ? crypto.randomUUID() - : (await sha256Hex(JSON.stringify([owner, name, key]))).slice(0, 32); + const taskId = crypto.randomUUID(); const now = new Date().toISOString(); const task: Task = { taskId, @@ -222,8 +211,7 @@ export function createTaskLayer( }; // The record is durable before the id goes out, and before the work is queued. - const existing = await store.create(task); - if (existing) return startedResult(existing, toolNames, true); + if (await store.create(task)) throw new Error(`Task id ${taskId} is already taken`); let dispatchId: string | undefined; try { dispatchId = await dispatcher.dispatch(task); @@ -237,7 +225,7 @@ export function createTaskLayer( throw error; } const saved = dispatchId ? await store.update(taskId, { dispatchId }) : task; - return startedResult(saved, toolNames, false); + return startedResult(saved, toolNames); }; server.registerTool( @@ -386,14 +374,8 @@ export function createTaskLayer( return { define, register, createExecuteHandler, getTask: read, cancelTask }; } -function startedResult( - task: Task, - toolNames: { status: string }, - deduplicated: boolean, -): Record { - const lead = deduplicated - ? `Task ${task.taskId} already exists for this request (status: ${task.status}).` - : `Started task ${task.taskId}.`; +function startedResult(task: Task, toolNames: { status: string }): Record { + const lead = `Started task ${task.taskId}.`; return { content: [ { diff --git a/packages/mcp-toolkit/src/tasks/index.ts b/packages/mcp-toolkit/src/tasks/index.ts index 7fe0f43..4f7e89d 100644 --- a/packages/mcp-toolkit/src/tasks/index.ts +++ b/packages/mcp-toolkit/src/tasks/index.ts @@ -1,6 +1,6 @@ /** * `@upstash/mcp-toolkit/tasks`: long-running tools for MCP servers on the official TypeScript SDK. - * The Upstash backends are in `@upstash/mcp-toolkit/tasks/upstash`. + * The Upstash backends are in `@upstash/mcp-toolkit/upstash`. */ export { createTaskLayer, @@ -18,7 +18,6 @@ export { export type { Principal, PrincipalResolver } from "../shared/auth.js"; export { - dispatchKey, isTerminal, TERMINAL_STATUSES, UnknownTaskError, diff --git a/packages/mcp-toolkit/src/tasks/types.ts b/packages/mcp-toolkit/src/tasks/types.ts index bd7fe20..860d942 100644 --- a/packages/mcp-toolkit/src/tasks/types.ts +++ b/packages/mcp-toolkit/src/tasks/types.ts @@ -104,8 +104,7 @@ export interface TaskStore { export interface TaskDispatcher { /** * Durably accepts a delivery and returns a handle for {@link cancel}. Must be idempotent per - * task record: dedupe on {@link dispatchKey}, not the task id alone, because a keyed task id is - * reused once its record expires. + * task id, which is a random UUID. */ dispatch(task: Task): Promise; @@ -138,7 +137,3 @@ export type TaskContext = { /** True once the client cancelled, or the record expired. Cooperative: check at step boundaries. */ isCancelled(): Promise; }; - -/** A dedupe key unique to one task record: `-`. */ -export const dispatchKey = (task: Pick): string => - `${task.taskId}-${Date.parse(task.createdAt)}`; diff --git a/packages/mcp-toolkit/src/tasks/upstash.ts b/packages/mcp-toolkit/src/tasks/upstash.ts deleted file mode 100644 index 44e24fc..0000000 --- a/packages/mcp-toolkit/src/tasks/upstash.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * The Upstash task backends: Redis for the record, QStash or Upstash Workflow for the work. - * Needs `@upstash/redis`, `@upstash/qstash` and `@upstash/workflow` 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-toolkit/src/test-support.ts b/packages/mcp-toolkit/src/test-support.ts index a10d28d..af34522 100644 --- a/packages/mcp-toolkit/src/test-support.ts +++ b/packages/mcp-toolkit/src/test-support.ts @@ -95,3 +95,10 @@ export class ManualDispatcher implements TaskDispatcher } }): string { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; +} diff --git a/packages/mcp-toolkit/src/upstash.ts b/packages/mcp-toolkit/src/upstash.ts new file mode 100644 index 0000000..3e6d684 --- /dev/null +++ b/packages/mcp-toolkit/src/upstash.ts @@ -0,0 +1,26 @@ +/** + * `@upstash/mcp-toolkit/upstash`: the Upstash backends for both layers. Redis keeps the task + * records and event subscriptions; QStash (or Upstash Workflow) delivers the work and the webhooks. + */ +export { + RedisTaskStore, + QStashDispatcher, + DEFAULT_RETRIES, + DEFAULT_RETRY_DELAY, + DEFAULT_TASK_PREFIX, + type RedisTaskStoreConfig, + type QStashDispatcherConfig, +} from "./tasks/backends/qstash.js"; + +export { WorkflowDispatcher, type WorkflowDispatcherConfig } from "./tasks/backends/workflow.js"; + +export { + RedisSubscriptionStore, + QStashDelivery, + DEFAULT_EVENTS_PREFIX, + type RedisSubscriptionStoreConfig, + type QStashDeliveryConfig, +} from "./events/backends/qstash.js"; + +export { SDK_TELEMETRY } from "./telemetry.js"; +export { VERSION } from "./version.js"; diff --git a/packages/mcp-toolkit/tsup.config.ts b/packages/mcp-toolkit/tsup.config.ts index bd01bce..97f1398 100644 --- a/packages/mcp-toolkit/tsup.config.ts +++ b/packages/mcp-toolkit/tsup.config.ts @@ -3,8 +3,8 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: { "tasks/index": "src/tasks/index.ts", - "tasks/upstash": "src/tasks/upstash.ts", "events/index": "src/events/index.ts", + upstash: "src/upstash.ts", }, format: ["esm"], dts: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcd1bcc..b07afc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -412,6 +412,15 @@ importers: packages/mcp-toolkit: dependencies: + '@upstash/qstash': + specifier: ^2.11.3 + version: 2.12.0 + '@upstash/redis': + specifier: ^1.38.0 + version: 1.38.4 + '@upstash/workflow': + specifier: ^1.3.3 + version: 1.4.0(zod@4.4.3) zod: specifier: ^4.2.0 version: 4.4.3 @@ -422,15 +431,6 @@ importers: '@modelcontextprotocol/server': specifier: ^2.0.0 version: 2.3.1 - '@upstash/qstash': - specifier: ^2.11.3 - version: 2.12.0 - '@upstash/redis': - specifier: ^1.38.0 - version: 1.38.4 - '@upstash/workflow': - specifier: ^1.3.3 - version: 1.4.0(zod@4.4.3) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -990,105 +990,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1194,56 +1178,48 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-gnu@16.2.9': resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.2.6': resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-arm64-musl@16.2.9': resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.2.6': resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-gnu@16.2.9': resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.2.6': resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-linux-x64-musl@16.2.9': resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.2.6': resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} @@ -2190,42 +2166,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.2.7': resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.2.7': resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.2.7': resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.2.7': resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.2.7': resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.2.7': resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} @@ -2282,79 +2252,66 @@ packages: resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.0': resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.0': resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.0': resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.0': resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.0': resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.0': resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.0': resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.0': resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.0': resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.0': resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.0': resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.0': resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.62.0': resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} @@ -2499,28 +2456,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -4085,28 +4038,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} From 148851197ff9e4fc7574173dc537de04b815cc06 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 13:18:14 +0000 Subject: [PATCH 27/33] DX-3022: mcp-toolkit: check the resolved principal with !caller Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A5x4btgiRE3FKdpUW527Wu --- packages/mcp-toolkit/src/events/core.ts | 4 ++-- packages/mcp-toolkit/src/tasks/core.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/mcp-toolkit/src/events/core.ts b/packages/mcp-toolkit/src/events/core.ts index 68d5556..661263b 100644 --- a/packages/mcp-toolkit/src/events/core.ts +++ b/packages/mcp-toolkit/src/events/core.ts @@ -428,7 +428,7 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { const caller = callerOf(ctx); const who = await resolvePrincipal(principal, caller); - if (who === undefined) throw notAuthenticated(); + if (!who) throw notAuthenticated(); const owner = who.id; if ( definition.config.authorize && @@ -477,7 +477,7 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { async function unsubscribe(params: z.output, ctx: unknown) { const owner = (await resolvePrincipal(principal, callerOf(ctx)))?.id; - if (owner === undefined) throw notAuthenticated(); + if (!owner) throw notAuthenticated(); const parsed = definitions.get(params.name)?.input.safeParse(params.arguments ?? {}); if (parsed?.success && params.delivery.url) { const args = parsed.data as Record; diff --git a/packages/mcp-toolkit/src/tasks/core.ts b/packages/mcp-toolkit/src/tasks/core.ts index 249b030..0717ce6 100644 --- a/packages/mcp-toolkit/src/tasks/core.ts +++ b/packages/mcp-toolkit/src/tasks/core.ts @@ -187,7 +187,7 @@ export function createTaskLayer( ): void { const callback = async (args: unknown, context: unknown): Promise> => { const owner = await callerOf(context); - if (owner === undefined) return notAuthenticatedResult(); + if (!owner) return notAuthenticatedResult(); const taskId = crypto.randomUUID(); const now = new Date().toISOString(); const task: Task = { @@ -259,7 +259,7 @@ export function createTaskLayer( }, (async ({ taskId }: { taskId: string }, context: unknown) => { const caller = await callerOf(context); - if (caller === undefined) return notAuthenticatedResult(); + if (!caller) return notAuthenticatedResult(); const task = await owned(taskId, caller); return task ? statusResult(task) : unknownTaskResult(taskId); }) as never, @@ -277,7 +277,7 @@ export function createTaskLayer( }, (async ({ taskId }: { taskId: string }, context: unknown) => { const caller = await callerOf(context); - if (caller === undefined) return notAuthenticatedResult(); + if (!caller) return notAuthenticatedResult(); if (!(await owned(taskId, caller))) return unknownTaskResult(taskId); const task = await cancelTask(taskId); return task ? statusResult(task) : unknownTaskResult(taskId); From 4872b5a94eab7a8569475bbf139ec39c31aa954e Mon Sep 17 00:00:00 2001 From: Cahid Arda Date: Thu, 8 Oct 2026 17:34:04 +0300 Subject: [PATCH 28/33] =?UTF-8?q?DX-3022:=20mcp-toolkit:=20simplify,=20and?= =?UTF-8?q?=20fix=20event=20authorization=20and=20Workf=E2=80=A6=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * DX-3022: mcp-toolkit: simplify, and fix event authorization and Workflow signature checks Security: - WorkflowDispatcher now binds QStash signatures to its URL. Workflow's serve() verifies only body and signature, so a signature issued for any other endpoint of the account was accepted and the task ran (reverting the fix makes the new test fail with the task executed). - emit(payload) is the only form: an event's values come from the payload, and the same values route it and authorize it. Delivery-time authorize gets the event's values instead of the subscription's own (possibly empty) filter, so a subscriber with no filter can no longer receive every document's events. - Task ids are validated as UUIDs; subscribe identifies the caller before validating anything; the events secretKey must be >= 32 random bytes; every IP literal is refused as a callback host. - Signature tests use a real QStash Receiver with real JWTs (wrong key, wrong URL, wrong body, expired) instead of a stubbed verify. Simplified: - Removed personal/to/match/explicit args, principal context, taskFinishedEvent and onSettle, toolNames, queuedMessage, per-tool ttl overrides, ttlMs: null, getTask/cancelTask, dispatchId, UnknownTaskError, input_required, the events fetch/timeoutMs/defaults options and per-backend headers. - Dispatchers and deliveries receive their entry points in createExecuteHandler / createDeliveryHandler instead of attach(). - One subscription index per event with matching in JS, instead of up to 256 hashed subset indexes; plain MULTI writes instead of the create and put Lua scripts (the cancel-vs-complete guard stays). - Memory backends moved to test-support (not shipped); public exports trimmed. - README rewritten: minimal snippets, details in toggles. Claude-Session: https://claude.ai/code/session_012geGTRLRnPqYtaASmBdhE1 * DX-3022: mcp-toolkit README: type authInfo as the SDK's AuthInfo Claude-Session: https://claude.ai/code/session_012geGTRLRnPqYtaASmBdhE1 * DX-3022: mcp-toolkit: type principal's auth as the SDK's AuthInfo Drops the structural CallerAuth copy; Caller.auth and AuthorizeCaller.auth are the SDK's own AuthInfo, and the README's principal takes it from the SDK too. Claude-Session: https://claude.ai/code/session_012geGTRLRnPqYtaASmBdhE1 --- .changeset/mcp-toolkit-initial.md | 25 +- CLAUDE.md | 125 +- .../mcp-toolkit-demo/app/lib/deploy-watch.ts | 12 - examples/mcp-toolkit-demo/scripts/smoke.mjs | 2 +- packages/mcp-toolkit/README.md | 1026 +++++------------ .../mcp-toolkit/src/events/backends/memory.ts | 78 -- .../src/events/backends/qstash.test.ts | 177 +-- .../mcp-toolkit/src/events/backends/qstash.ts | 149 +-- packages/mcp-toolkit/src/events/core.test.ts | 526 +++++---- packages/mcp-toolkit/src/events/core.ts | 439 +++---- packages/mcp-toolkit/src/events/index.ts | 35 +- packages/mcp-toolkit/src/events/tasks.ts | 79 -- packages/mcp-toolkit/src/events/types.ts | 51 +- packages/mcp-toolkit/src/events/webhooks.ts | 138 +-- packages/mcp-toolkit/src/shared/auth.ts | 53 +- packages/mcp-toolkit/src/shared/clients.ts | 42 +- packages/mcp-toolkit/src/shared/env.ts | 9 + .../mcp-toolkit/src/tasks/backends/memory.ts | 115 -- .../src/tasks/backends/qstash.test.ts | 277 ++--- .../mcp-toolkit/src/tasks/backends/qstash.ts | 202 ++-- .../src/tasks/backends/workflow.test.ts | 134 ++- .../src/tasks/backends/workflow.ts | 74 +- packages/mcp-toolkit/src/tasks/core.test.ts | 127 +- packages/mcp-toolkit/src/tasks/core.ts | 387 +++---- packages/mcp-toolkit/src/tasks/index.ts | 41 +- packages/mcp-toolkit/src/tasks/types.ts | 85 +- packages/mcp-toolkit/src/test-support.ts | 270 ++++- packages/mcp-toolkit/src/upstash.ts | 7 - 28 files changed, 1800 insertions(+), 2885 deletions(-) delete mode 100644 packages/mcp-toolkit/src/events/backends/memory.ts delete mode 100644 packages/mcp-toolkit/src/events/tasks.ts create mode 100644 packages/mcp-toolkit/src/shared/env.ts delete mode 100644 packages/mcp-toolkit/src/tasks/backends/memory.ts diff --git a/.changeset/mcp-toolkit-initial.md b/.changeset/mcp-toolkit-initial.md index 5b933f1..9c37727 100644 --- a/.changeset/mcp-toolkit-initial.md +++ b/.changeset/mcp-toolkit-initial.md @@ -7,22 +7,21 @@ Add `@upstash/mcp-toolkit`: durable building blocks for MCP servers on the offic `@upstash/mcp-toolkit/tasks` — long-running tools. A task tool answers immediately with a task id; the model polls the shared `task_status` tool for progress and the result, and can stop the task with `task_cancel`. They are ordinary MCP tools, so they work in every client today. The record -lives in a `TaskStore` and the work runs behind a `TaskDispatcher`, and the dispatcher owns its delivery -endpoint (`export const POST = tasks.createExecuteHandler()`) and always verifies QStash's -signature. Tasks are declared once at module scope with `tasks.define(...)` and attached to each -request's server with `tasks.register(server)`. A required `principal` scopes tasks to their -caller (and subscriptions to theirs), and `onSettle` fires once per finished task. +lives in a `TaskStore` and the work runs behind a `TaskDispatcher`, and the dispatcher owns its +delivery endpoint (`export const POST = tasks.createExecuteHandler()`) and always verifies QStash's +signature against the URL it published to. Tasks are declared once at module scope with +`tasks.define(...)` and attached to each request's server with `tasks.register(server)`. A required +`principal` scopes tasks to their caller (and subscriptions to theirs). `@upstash/mcp-toolkit/events` — MCP Events with webhook delivery, as ChatGPT ships it. Typed -`events.define(...)` handles with `emit(payload)`, a required `authorize` checked at subscribe and -again before every delivery, `events/list`, `events/subscribe` and -`events/unsubscribe` registered on your server, a signed verification challenge, deterministic -subscription ids, expiry and refresh, SSRF checks on callback URLs, and signing secrets encrypted -at rest. `QStashDelivery` signs each attempt with Standard Webhooks and lets QStash retry failures -(`export const POST = events.createDeliveryHandler()`). `taskFinishedEvent` bridges the two, so an event-capable host can -stop polling. +`events.define(...)` handles with `emit(payload)`: the payload's values route the event and are what +the required `authorize` is checked against, at subscribe and again before every delivery. +`events/list`, `events/subscribe` and `events/unsubscribe` are registered on your server, with a +signed verification challenge, deterministic subscription ids, expiry and refresh, SSRF checks on +callback URLs, and signing secrets encrypted at rest. `QStashDelivery` signs each attempt with +Standard Webhooks and lets QStash retry failures (`export const POST = events.createDeliveryHandler()`). -The package uses WebCrypto only, so they run on Node and edge runtimes. +The package uses WebCrypto only, so it runs on Node and edge runtimes. `@upstash/mcp-toolkit/upstash` holds the Upstash backends for both: `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher`, `RedisSubscriptionStore` and `QStashDelivery`. diff --git a/CLAUDE.md b/CLAUDE.md index 063d5f5..faa8898 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -683,8 +683,12 @@ implements TanStack AI's own backend contracts (see its section below) — keep `dist/upstash.js`. `src/tasks/index.ts` and `src/events/index.ts` must never import an `@upstash/*` package (check the built `dist/{tasks,events}/index.js` imports); the backends live in `src/{tasks,events}/backends/` and are exported only from `src/upstash.ts`. - There is no root export. Shared: `src/shared/{auth,clients,crypto}.ts` (principal + `authOf`, - lazy env clients + `nonRetryable`, WebCrypto helpers), `src/telemetry.ts`, `src/version.ts`. + There is no root export. Shared: `src/shared/{auth,clients,crypto,env}.ts` (principal resolution, + lazy Upstash clients + `nonRetryable` + `readQStashJson` + `boundToUrl`, WebCrypto helpers, and + the dependency-free `env`/`INTERNAL_ERROR`), `src/telemetry.ts`, `src/version.ts`. The cores may + import `shared/env.ts` but **never `shared/clients.ts`** — it imports `@upstash/redis` and + `@upstash/qstash`, and the bundler would pull them into `/tasks` and `/events` (this happened + once, caught only by grepping the built chunks: `grep -l "@upstash/" dist/chunk-*.js`). **WebCrypto only** (no `node:crypto` / `node:net`), so it runs on edge runtimes; `signWebhook`, `SecretBox.seal/open` and `subscriptionId` are async for that reason. - **Tools mode, not the Tasks extension (since 2026-10).** A task tool answers with an ordinary tool @@ -706,46 +710,63 @@ implements TanStack AI's own backend contracts (see its section below) — keep `ctx.http.req`), may be async, and stamps `task.owner` / `subscription.owner`. `auth` is only what the app's route passed to `handler.fetch(request, { authInfo })` after verifying the token: the SDK never derives it from headers. `request` is unverified, for cookie/session apps; docs warn - against trusting caller-set headers. `authorize` gets `{ principal, auth, request }`. `PrincipalResolver` cannot return `undefined` - (typed `Principal | Promise`): it **throws** to refuse, and `resolvePrincipal` also - fails closed on a rejection or an answer without a non-empty string id. Refused = not authenticated: the call is + against trusting caller-set headers. `PrincipalResolver` returns a plain **string** + (`string | Promise`; the old `{ id, context }` form is gone): it **throws** to refuse, + and `resolvePrincipal` also fails closed on a rejection or anything but a non-empty string. + Refused = not authenticated: the call is refused (`isError` "Not authenticated" for tools, reason `not_authenticated` for events/*) — no anonymous mode, fail closed; a stored task without an owner answers nobody. Single-tenant servers - pass `principal: () => "local"`. `task_status` / `task_cancel` report a non-owned task as - *unknown* (no existence oracle). Owners are JSON-encoded on write, so auto-deserialization hands - them back as strings and a plain `===` compares them. The public `TaskLayer` is only - `{ define, register, createExecuteHandler, getTask, cancelTask }`; a dispatcher gets `run` / - `fail` through `attach`. Tests drive deliveries with `ManualDispatcher` / `CountingDispatcher` - from `src/test-support.ts`, not with knobs on the public classes. + pass `principal: () => "local"`. `task_status` / `task_cancel` take `z.uuid()` task ids (so + nothing else reaches a Redis key) and report a non-owned task as *unknown* (no existence oracle). + Owners are JSON-encoded on write, so auto-deserialization hands them back as strings and a plain + `===` compares them. The public `TaskLayer` is only `{ define, register, createExecuteHandler }`; + a dispatcher gets `run` / `fail` as the argument of its `createExecuteHandler(endpoints)` (there + is no `attach`). Tests drive deliveries with `ManualDispatcher` / `InlineDispatcher` from + `src/test-support.ts` — call `tasks.createExecuteHandler()` once to connect them — and the + in-memory backends (`MemoryTaskStore`, `MemorySubscriptionStore`, `InlineDelivery`) live there + too: they are **not shipped**. - **No `idempotencyKey` (removed 2026-10-08, keep it simple; add back only if someone asks).** Task - ids are always `crypto.randomUUID()`. `TaskStore.create` stays create-if-absent in one Lua script - (atomic `HSET` + `PEXPIRE`, never overwrites); the core throws if an id is somehow taken. + ids are always `crypto.randomUUID()`, so `TaskStore.create` is a plain `MULTI` (`HSET` + + `PEXPIRE`) returning `void` — no create-if-absent script, since a collision cannot happen. + `ttlMs` is always a positive number (`null` = unlimited was removed: it let tasks live forever). - **Signing keys required, no fail-open.** `QStashDispatcher`, `WorkflowDispatcher` and `QStashDelivery` verify with `receiver` or a `Receiver` from the `QSTASH_*_SIGNING_KEY` env vars, and throw on the first request when neither exists (resolved outside the verify `try`, so it is not misreported as a 401). `WorkflowDispatcher` passes `receiver` to `serve()` explicitly because Workflow's own default silently skips verification when the env vars are missing; the `serve` handler is built on the first request so route modules still evaluate at build time. + **Workflow does not bind the signature to the URL** (verified in `@upstash/workflow` 1.3.3 and + 1.4.0: `verifyRequest` calls `verifier.verify({ body, signature })` with no `url`), so a QStash + signature issued for *any other endpoint of the same account* was accepted and the task ran — + measured by reverting the fix. The receiver handed to `serve()` is `boundToUrl(receiver, url)`; + `workflow.test.ts` "refuses a signature QStash issued for another endpoint" guards it. + Signature tests use a **real** `Receiver` with test keys and real HS256 JWTs (`signQStash` / + `qstashRequest` in `test-support.ts`): wrong key, wrong `sub`, wrong body hash, expired — never a + stubbed `verify`. - **Telemetry:** `addTelemetry` (Redis) and `addQStashTelemetry` (QStash `Client`, or a Workflow `Client` via its `.client`) append `@upstash/mcp-toolkit@` to `Upstash-Telemetry-Sdk`, once per client; `enableTelemetry: false` on each backend opts out. -- **Round trips:** `RedisTaskStore.create` is one Lua script (create-if-absent + `PEXPIRE`); - `update` and `settle` share one guarded-write script (terminal statuses hardcoded as JSON literals) - that returns `[changed, ...HGETALL]`. `settle` returns `{ task, settled }` (null only when the task - is missing): `settled` drives `onSettle` and dispatcher cancel, and the record comes back even when - nothing changed, so `cancelTask` is one write. `SubscriptionStore.delete` takes - `{ id, event, argsKey }` so the Redis store drops record + index in one pipeline. +- **Round trips:** `RedisTaskStore.create` is one `MULTI`; `update` and `settle` share one + guarded-write script (terminal statuses hardcoded as JSON literals) that returns `HGETALL` after + the call. `update` returns `void` (no-op on a missing or terminal task; `UnknownTaskError` is + gone), `settle` returns the task after the call (null only when missing). `task_cancel` calls + `dispatcher.cancel(taskId)` when the returned status is `cancelled` — `cancel` must be + idempotent. There is no `dispatchId`: `QStashDispatcher.cancel` is a no-op (a redelivery of a + cancelled task finds it settled and does nothing), Workflow's run id is the task id. - **Dispatch dedupe on the task id:** QStash `deduplicationId` and Workflow `workflowRunId` are the - task id, and the `task.finished` event id is `evt_task_${taskId}`. That is only safe because ids + task id. That is only safe because ids are random: if keyed (deterministic) ids ever come back, dedupe must move to a per-record key (taskId + createdAt), since QStash keeps dedup ids for 10 minutes and Workflow refuses a reused run id. A failed `dispatch` settles the fresh record `failed` ("Could not be queued") and rethrows. - **Workflow context is inferred** from the dispatcher (`TaskDispatcher`), so `createTaskLayer({ dispatcher: new WorkflowDispatcher(...) })` types `task.run` / `task.sleep` with no type argument; a test in `workflow.test.ts` keeps it that way. -- **`RedisSubscriptionStore.find` batches:** one `ZRANGE` per argument subset (up to 256), then - `MGET`s of the matches, each split into requests of at most 1,000 commands, so a popular event - never sends one giant request. +- **`RedisSubscriptionStore` = one index per event** (`idx:`, a zset scored by + expiry, no TTL — bounded by the number of event names) plus `sub:` with `PX`. `put` + and `delete` are plain `MULTI`s (the old Lua PUT and the 256-subset `argsKey` index are gone); + `put` prunes expired members. `find(event)` = one `ZRANGE` by score + `MGET`s of at most 1,000 + keys, and the **layer** filters in JS (`matches(sub.args, values)`). O(subscribers of the event) + per emit — fine at MCP-host scale; revisit only if an event gets tens of thousands. - **No secret defaults:** `MCP_EVENTS_SECRET_KEY` has no fallback, demo included; the event layer resolves it on first use (not at construction, so builds without env still work) and a missing key throws rather than dropping deliveries. @@ -761,8 +782,9 @@ implements TanStack AI's own backend contracts (see its section below) — keep 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 + retry header; there is no max-retries header, which is why the final failure comes from QStash's + `failureCallback` (posted to the same route, `sourceBody` = the original body) rather than from + counting attempts (`isFinalQStashAttempt` is gone). 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** @@ -770,8 +792,9 @@ implements TanStack AI's own backend contracts (see its section below) — keep 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 dispatcher owns its delivery endpoint** (`TaskDispatcher.createExecuteHandler(endpoints)`, + surfaced as `tasks.createExecuteHandler()`; `EventDelivery.createDeliveryHandler(send)` likewise, + sharing `readQStashJson` for verify + parse): signature verification, task-id parsing 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, **500** only when the task threw, and **489 + `Upstash-NonRetryable-Error: @@ -802,33 +825,41 @@ implements TanStack AI's own backend contracts (see its section below) — keep config. The demo's `pnpm smoke` is the end-to-end check (QStash dev server + both servers + the filtered `deploy.finished` webhook from the Deploy Watch server into the demo's receiver). The demo keeps tasks (Report Desk, `/api/mcp`) and events (Deploy Watch, `/api/deploy-watch`) in separate - servers on purpose; `task.finished` is covered by unit tests only. + servers on purpose. The events tests stub the **global** `fetch` (`vi.stubGlobal`) — the layer + has no `fetch` option. - **Events facts.** Wire format follows ChatGPT's MCP Events (webhook only): `events/list|subscribe| unsubscribe` registered via `server.server.setRequestHandler(method, { params }, handler)` and `registerCapabilities({ events: {} } as never)` (the SDK has no events types). Subscription id = - `sub_` + sha256(canonical `[subscriber, url, event, args]`). Matching = exact canonical args: emit - enumerates every subset of its args (cap 8 keys) and the store looks those keys up (index per - `(event, argsKey)`). **Access model (replaced the review's "every emit names owners"):** the - filter says *what*, a **required** `authorize` says *who may* — it runs at subscribe/refresh - (`phase: "subscribe"`, with `auth`/`request`) and again in `send()` before every delivery - (`phase: "deliver"`, only the stored `principal` + `context`; the token is never stored), so - revoked access stops events; refused deliveries are `dropped`, a throwing `authorize` is `retry`. - `to` optionally narrows an emit; `personal: true` events make `to` required in the type and at - runtime (`task.finished` is personal, `to: task.owner`). `principal` may return - `{ id, context }`; context is non-secret JSON (≤ 4 KB) stored as `subscription.context`. The - stored field is `subscriber` (not "owner", which read like document ownership). `emit`'s `args` - is optional only when every input key is a payload key; otherwise the type requires it, and a - runtime check throws when a required input field has no value. Secrets are AES-256-GCM sealed under `secretKey` (`MCP_EVENTS_SECRET_KEY`); a - refresh with the same secret skips the challenge. A failed challenge is always the same + `sub_` + sha256(canonical `[subscriber, url, event, args]`), so unsubscribe/refresh can only ever + touch the caller's own record (tested). **An event's values come from ONE place, the payload** + (the user's call, 2026-10-08: "it doesn't make sense for emit to get the docId as two + parameters"): `emit(payload, { eventId? })` is the whole API, `define` throws unless every + `input` field is also a `payload` field, and `valuesOf(input, payload)` gives the values used + both to **match** (a subscription matches when every arg it gave equals the payload's value) and + to **authorize**. **Access model:** the filter says *what*, a **required** `authorize` says *who + may* — at subscribe/refresh with the subscription's args (`phase: "subscribe"`, with + `auth`/`request`), and in `send()` before every delivery with **the event's values, not the + subscription's args** (`phase: "deliver"`, principal only; the token is never stored). That is the + fix for the review's medium finding: checking `sub.args` let a subscriber with no filter (`{}`) + pass `authorize({})` and receive every document's events, and the old explicit `emit(payload, + { args })` could route doc A's payload to doc B's readers. `core.test.ts` "checks a subscriber + without a filter against each event's own values" guards it (mutation-checked). A refusal at + delivery is final (done), a throwing `authorize` retries. `send` returns a boolean (`true` = + done, `false` = retry). Removed on purpose — don't bring back: `personal`, `to`, `match`, + explicit `args`, principal `context`, `taskFinishedEvent`/`onSettle`, `timestamp`, + `EmitResult.matched`, `list()`, `timeoutMs`/`fetch`/`defaults` on the event layer. + `subscribe` resolves the principal **before** validating anything (no info to anonymous + callers). Secrets are AES-256-GCM sealed under `secretKey` (`MCP_EVENTS_SECRET_KEY`), which must + decode to **≥ 32 base64 bytes** (`openssl rand -base64 32`); `SecretBox.open` returns `null` on + a tampered/rotated secret. A refresh with the same secret skips the challenge. A failed challenge is always the same `ProtocolError(-32015, "Callback URL failed verification")` with no reason or status (no network probing); the detail is `console.warn`ed. The challenge answer is read capped at 4 KB, delivery - bodies are cancelled, and 3xx is `dropped`. `callbackUrlProblem` strips trailing dots and checks - IPv4 embedded in IPv6 (mapped, compatible, NAT64, 6to4); it does not resolve DNS. Bad params are - `-32602` with a `reason`. + bodies are cancelled, and 3xx is dropped. `callbackUrlProblem` strips trailing dots and refuses + **every IP literal** (v4 after the URL parser's normalization, and any `[…]` v6) plus + localhost/single-label/`.local`/`.internal`; the old private-range/NAT64/6to4 parser is gone. + It does not resolve DNS. Bad params are `-32602` with a `reason`. **QStash `deduplicationId` cannot contain `:`** (dev server answers 400) — ids are `${eventId}_${subscriptionId}` sanitized; this was caught only by the e2e smoke. - The task bridge is `createTaskLayer({ onSettle })`, called only by the write that performed the - terminal transition; its errors are logged and swallowed because the task is already settled. - **Local dev needs the QStash dev server** (`npx @upstash/qstash-cli dev`) — it prints deterministic creds. `APP_URL` must be reachable *from QStash*. diff --git a/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts index ab7e364..e15be20 100644 --- a/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts +++ b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts @@ -25,18 +25,6 @@ export const events = createEventLayer({ delivery: new QStashDelivery({ url: `${APP_URL}/api/deploy-watch/events` }), // `secretKey` is read from MCP_EVENTS_SECRET_KEY (see .env.example). There is no fallback. principal: () => DEMO_USER, - // Logs what the host answers to the challenge and to every delivery. - fetch: async (input, init) => { - const response = await fetch(input, init); - const body = await response - .clone() - .text() - .catch(() => ""); - console.log( - `[deploy-watch] POST ${String(input).slice(0, 80)} -> ${response.status} ${body.slice(0, 200)}`, - ); - return response; - }, // The demo's own receiver runs on localhost. Never set this in production: it disables the // checks that stop a subscriber from pointing your server at internal addresses. allowInsecureCallbacks: /^http:\/\/(127\.0\.0\.1|localhost)/.test(APP_URL), diff --git a/examples/mcp-toolkit-demo/scripts/smoke.mjs b/examples/mcp-toolkit-demo/scripts/smoke.mjs index ab3aeeb..5678f8d 100644 --- a/examples/mcp-toolkit-demo/scripts/smoke.mjs +++ b/examples/mcp-toolkit-demo/scripts/smoke.mjs @@ -118,7 +118,7 @@ if (ENDPOINT === "/api/mcp") { (await fetch(`${BASE}${WATCH}/deploys`, { method: "POST", body: JSON.stringify(body) })).json(); const staging = await report({ environment: "staging", commit: "staging only" }); const production = await report({ environment: "production", status: "failed" }); - console.log("reported", staging.deploy.id, "(staging,", staging.matched, "matched)", production.deploy.id, "(production,", production.matched, "matched)"); + console.log("reported", staging.deploy.id, "(staging)", production.deploy.id, "(production)"); let received = []; for (let i = 0; i < 20; i++) { diff --git a/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md index cbe1d50..d667f3d 100644 --- a/packages/mcp-toolkit/README.md +++ b/packages/mcp-toolkit/README.md @@ -1,41 +1,67 @@ # @upstash/mcp-toolkit -Durable building blocks for MCP servers on the official TypeScript SDK, backed by Upstash Redis, -QStash and Workflow. +Durable building blocks for MCP servers built on the official TypeScript SDK +(`@modelcontextprotocol/server`), running on Upstash Redis and QStash. -| Entry point | What it gives your server | -| --- | --- | -| [`@upstash/mcp-toolkit/tasks`](#tasks-long-running-tools) | **Long-running tools.** A tool answers at once with a task id; the model polls `task_status` for progress and the result. Works in every client today. | -| [`@upstash/mcp-toolkit/events`](#events-webhooks-that-wake-the-agent) | **MCP Events.** Hosts subscribe to your events and get a signed webhook when one happens, so an agent wakes up instead of polling. Works in ChatGPT today. | -| `@upstash/mcp-toolkit/upstash` | **The Upstash backends for both:** `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher`, `RedisSubscriptionStore`, `QStashDelivery`. | +- **`/tasks`: long-running tools.** The tool answers right away with a task id, and the model + polls `task_status` until the result is ready. The work runs on QStash, so the process that took + the call can die and the client's tool-call timeout no longer limits it. Works in every MCP client. +- **`/events`: MCP Events.** A host subscribes to an event on your server and gets a signed + webhook when it happens, so the agent wakes up instead of polling. Works in ChatGPT today. +- **`/upstash`: the Upstash backends** for both: `RedisTaskStore`, `QStashDispatcher`, + `WorkflowDispatcher`, `RedisSubscriptionStore`, `QStashDelivery`. -`/tasks` and `/events` are generic: the layers, the interfaces a backend implements, and in-memory -backends for tests. Everything Upstash-specific is in `/upstash`. The two layers also compose: a -`task.finished` event tells an event-capable host that a task settled, so it can skip polling. +```bash +npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod +``` -## Install +Environment variables: ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server +UPSTASH_REDIS_REST_URL=... +UPSTASH_REDIS_REST_TOKEN=... +QSTASH_TOKEN=... +QSTASH_CURRENT_SIGNING_KEY=... # required: delivery routes refuse to run without them +QSTASH_NEXT_SIGNING_KEY=... +MCP_EVENTS_SECRET_KEY=... # events only. Generate with: openssl rand -base64 32 +APP_URL=https://your-app.com # used in the snippets below. QStash has to be able to reach it ``` -`@upstash/redis`, `@upstash/qstash` and `@upstash/workflow` come with the package, so there is -nothing else to install. The MCP SDK is a peer dependency, because your server already has its own. -The package uses WebCrypto only, so it runs on Node and on edge runtimes. +## Who is calling + +Both layers need `principal`: a function that returns the id of the user making the call. Tasks +belong to the user who started them, and subscriptions to the user who subscribed. Read it from the +token your MCP route has verified, and throw when there is none: -## Tasks: long-running tools +```ts +// lib/auth.ts +import type { AuthInfo } from "@modelcontextprotocol/server"; + +// `auth` is the AuthInfo your MCP route verified and passed to the SDK (see below). +export function principal({ auth }: { auth?: AuthInfo }): string { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; +} +``` -A long-running tool answers immediately with a task id instead of blocking. The model polls a -shared `task_status` tool for progress and, once it completes, the result. The task record lives -in Upstash Redis; the work runs through QStash or Upstash Workflow, so it survives the process that -accepted the call and is not bound by the client's tool-call timeout. With QStash the handler still -runs inside one function invocation; [Workflow](#choosing-a-dispatcher) lifts that limit. +
+More on principal + +- **A throw refuses the call.** There is no anonymous mode: if `principal` throws, rejects, or + returns anything but a non-empty string, the call is refused as not authenticated. +- **Use the user id, not `auth.clientId`.** The client id identifies the OAuth app, and every + ChatGPT user shares the same one. +- `auth` is only what your route passed to `handler.fetch(request, { authInfo })` (see below). The + SDK never fills it from headers. +- `principal` also receives `request`, for cookie or session apps: type its argument as `Caller` + (`{ auth, request }`, from `@upstash/mcp-toolkit/tasks` or `/events`). `request` is unverified, + so check the session yourself, and never trust a header like `x-user-id`. +- A server with no users of its own passes `principal: () => "local"`. -Everything is served as **ordinary MCP tools**, so it works in every client today — Claude Code, -Codex, Cursor, OpenCode, ChatGPT — with no client capability required. See -[Why tools, not the Tasks extension?](#why-tools-not-the-tasks-extension) +
-### Usage +## Long-running tools ```ts // lib/tasks.ts @@ -43,136 +69,69 @@ import { McpServer } from "@modelcontextprotocol/server"; import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; +import { principal } from "./auth"; export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - // Who is calling: your user id from the verified token. Required, see below. - principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return userId; - }, + principal, }); -// Module scope: every instance knows the handler, including an /api/execute instance that never -// serves an MCP request — the execute route finds a task's handler by the name stored on the task. +// Define at module scope, so the /api/execute instance knows the handler too. tasks.define( "generate_report", { description: "Generates a report on a topic.", inputSchema: z.object({ topic: z.string() }) }, - // `task` is the running task: report progress, and check whether it was cancelled. async ({ topic }, task) => { - const sources = await findSources(topic); - for (const [i, source] of sources.entries()) { - if (await task.isCancelled()) return {}; - await task.update(`Reading source ${i + 1}/${sources.length}`); // the model sees this - await read(source); - } + await task.update("Reading sources"); // the model sees this when it polls return { content: [{ type: "text", text: await writeReport(topic) }] }; }, ); -// Per request: attach every defined task tool, plus task_status and task_cancel. export function createServer() { const server = new McpServer({ name: "reports", version: "1.0.0" }); - tasks.register(server); + tasks.register(server); // adds generate_report, task_status and task_cancel return server; } ``` -Then two routes — the MCP endpoint, which is the SDK's own handler unchanged, and the one the work -is delivered to: - ```ts // app/api/mcp/route.ts -import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; import { createServer } from "../../lib/tasks"; const handler = createMcpHandler(() => createServer()); -export const POST = (request: Request) => handler.fetch(request); + +export async function POST(request: Request) { + // Clerk, WorkOS, Auth0, your own. `principal` reads the user id from `authInfo.extra`. + const authInfo: AuthInfo | undefined = await verifyToken(request); + if (!authInfo) return new Response("Unauthorized", { status: 401 }); + return handler.fetch(request, { authInfo }); +} ``` ```ts -// app/api/execute/route.ts +// app/api/execute/route.ts: QStash delivers the work here 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. Because the layer only registers tools, it works the same with -[`mcp-handler`](https://www.npmjs.com/package/mcp-handler) or any transport. - -`tools/list` now shows three tools: - -| Tool | What it does | -| ----------------- | ------------------------------------------------------------------- | -| `generate_report` | Starts the task and answers with its `taskId` at once | -| `task_status` | Progress while `working`; the handler's own result once `completed` | -| `task_cancel` | Asks the task to stop; idempotent | - -`task_status` and `task_cancel` are shared by every task tool on the server. - -The handler's second argument, `task`, has two calls, both optional: - -- `task.update(message)` writes a progress line. The model sees it as `statusMessage` on its next - `task_status`. -- `task.isCancelled()` is true once `task_cancel` was called (or the record expired). - Cancellation is cooperative: `task_cancel` flips the record and stops a pending delivery, but - running code only stops where it checks. +`tools/list` now has `generate_report` (starts the task and returns its id), `task_status` (shows +progress, then the handler's result) and `task_cancel`. To stop a cancelled task, check +`await task.isCancelled()` between steps. Cancelling is cooperative, so running code only stops +where it checks. -### Who is calling: `principal` - -`principal` is required. It maps each call to a stable caller id, usually your user id. Each task -records it as its owner, and `task_status` / `task_cancel` answer only for the caller who started -the task; another caller's id reads exactly like an unknown one. The same function owns event -subscriptions. - -It receives `{ auth, request }`: - -- **`auth`** is the MCP SDK's `AuthInfo`: `{ token, clientId, scopes, expiresAt?, extra? }`. The - SDK never fills it in from headers. **Your route does**, after verifying the bearer token with - your OAuth provider, by passing it to the handler: - - ```ts - // app/api/mcp/route.ts - export async function POST(request: Request) { - const authInfo = await verifyToken(request); // Clerk, WorkOS, Auth0, your own - if (!authInfo) return new Response("Unauthorized", { status: 401 }); - return handler.fetch(request, { authInfo }); - } - ``` - - `extra` is free-form: put the user id there in `verifyToken`, and read it back in `principal`, - as in the snippet above. Because the token was already checked, this is the path to prefer. - -- **`request`** is the raw HTTP request, for apps that authenticate with a cookie or session - instead of an `AuthInfo`. It is unverified, so check the session yourself (`principal` may be - async), and never trust a header like `x-user-id` that the caller can set. - -`principal` must return an id; its type does not allow `undefined`. When it cannot identify the -caller, it **throws**, and the call is refused with "Not authenticated". The layer fails closed -even if the types are bypassed: a throw, a rejected promise, or an answer without a non-empty -string id (an `as string` that lied, plain JavaScript) is a refusal too. There is no anonymous -mode. A server with no users of its own (a local tool, a demo) says so explicitly: - -```ts -principal: () => "local", -``` - -Key on the _user_, not `auth.clientId`: the client id identifies the OAuth app, which is often one -id shared by every user of a host like ChatGPT. - -### What the model sees +
+What the model sees ```jsonc -// generate_report → a handle, immediately +// generate_report: a handle, right away { "content": [{ "type": "text", "text": "Started task 0e30…. Call task_status with taskId \"0e30…\" in about 2s to check on it." }], "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 300000, "pollIntervalMs": 2000 } } -// task_status → progress… -{ "content": [{ "type": "text", "text": "Task 0e30… is working: Reading source 2. Check again in about 2s." }], - "structuredContent": { "taskId": "0e30…", "status": "working", "statusMessage": "Reading source 2" } } +// task_status: progress… +{ "content": [{ "type": "text", "text": "Task 0e30… is working: Reading sources. Check again in about 2s." }], + "structuredContent": { "taskId": "0e30…", "status": "working", "statusMessage": "Reading sources" } } // …then the handler's own content, as if the tool had run synchronously { "content": [{ "type": "text", "text": "Task 0e30… is completed: Completed" }, @@ -180,232 +139,123 @@ id shared by every user of a host like ChatGPT. "structuredContent": { "taskId": "0e30…", "status": "completed", "result": { "content": [ … ] } } } ``` -The states are `working`, `completed`, `failed` and `cancelled` (plus `input_required`, reserved); -the last three are terminal and never change again. The task object is the same shape as the -protocol's Tasks extension, so a native adapter can serve these records unchanged later. - -### Choosing a dispatcher +The states are `working`, `completed`, `failed` and `cancelled`; the last three are final. The +task object has the same shape as the one in the MCP Tasks extension. -Both serve the same route. They differ in how long the work may take. +If the model stops polling, nothing is lost: the work finishes anyway, and the result can be read +until the task expires (5 minutes by default; set `defaults: { ttlMs }` on the layer). -| | `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. +
+Work that takes longer than one function invocation: WorkflowDispatcher -**Start on QStash. Move to Workflow when the work outgrows a function.** A Workflow task, end to -end: +With QStash, the whole handler runs in one serverless invocation. If it goes past your platform's +time limit, it is killed, and the retry starts the handler from the beginning. Upstash Workflow +runs each step in its own invocation and replays finished steps from a journal, so a task has no +overall time limit. Only the dispatcher changes. The routes stay the same. ```ts -// lib/tasks.ts -import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/upstash"; -import * as z from "zod"; export const tasks = createTaskLayer({ store: new RedisTaskStore(), - // The dispatcher decides what `task` is: with Workflow it also has `run`, `sleep` and `call`. dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal, // the same function as in the QStash example - // A TTL longer than the work. It runs from creation and is never extended: once it passes, - // the record is gone and `isCancelled()` returns true. The default is 5 minutes. - defaults: { ttlMs: 60 * 60 * 1000 }, + principal, + defaults: { ttlMs: 60 * 60 * 1000 }, // keep the record longer than the work takes }); tasks.define( "migrate_workspace", - { - description: "Copies a workspace's documents to the new storage, one batch at a time.", - inputSchema: z.object({ workspaceId: z.string() }), - }, + { description: "Copies a workspace to new storage.", inputSchema: z.object({ workspaceId: z.string() }) }, async ({ workspaceId }, task) => { - // The work in steps. Each step is its own invocation, replayed from the journal once done. const batches = await task.run("plan", () => listBatches(workspaceId)); for (const [i, batch] of batches.entries()) { if (await task.isCancelled()) return {}; await task.update(`Copying batch ${i + 1}/${batches.length}`); await task.run(`copy-${i}`, () => copyBatch(batch)); } - await task.sleep("settle", 30); // waits without holding a function open - const report = await task.run("verify", () => verifyCopy(workspaceId)); - return { content: [{ type: "text", text: report }] }; + return { content: [{ type: "text", text: "Done" }] }; }, ); ``` -The routes are the same as with QStash: `export const POST = tasks.createExecuteHandler()` now -returns Workflow's own `serve()` handler. Each step is still one invocation, so keep every step -within your function limit; the task as a whole has none. - -
-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`. - -
- -### How it fits together +With Workflow, `task` also has `run`, `sleep` and `call`. The handler runs again from the top on +every step, and finished steps are replayed from the journal: -
-Who is responsible for what +- Put the work inside `task.run`. That makes it run once and survive a crash. +- `task.update(...)` does not need wrapping. +- Keep `task.isCancelled()` outside steps. It has to run again each time, or a later cancel is + never seen. +- Don't nest `task.run` calls. +- Each step must still fit within your function's time limit. -| | Owns | -| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`@upstash/mcp-toolkit/tasks`** | The tools: creating the record before replying, `task_status` / `task_cancel`, ownership, 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 task's TTL starts when the task is created and is never extended. When it runs out, the record +is deleted and `isCancelled()` returns true, so set `ttlMs` longer than the work takes. -The split is the whole design: a durable task id does not make the underlying work durable. +| | `QStashDispatcher` | `WorkflowDispatcher` | +| ---------------------------------- | -------------------------- | ------------------------------- | +| Survives the process dying | yes (QStash redelivers) | yes (replayed from the journal) | +| Can run longer than one invocation | no | yes, one invocation per step | +| On a retry | the whole task starts over | only the failed step runs again | +| Cancel stops a running task | at its next `isCancelled` | the run itself is cancelled |
-Flow: starting a task - -```mermaid -sequenceDiagram - participant M as Model - participant S as mcp-toolkit - participant St as TaskStore - participant D as TaskDispatcher - - M->>S: tools/call generate_report - S->>St: create(task) - Note over St: must commit before the reply —
the next poll may hit another instance - St-->>S: ok - S->>D: dispatch(task) - D-->>S: dispatchId - S->>St: update({ dispatchId }) - S-->>M: taskId + "call task_status" -``` +Retries and failures + +- The execute route answers **200** when the task ran (or had already finished), **500** when your + handler threw, so QStash tries again, and **489** with `Upstash-NonRetryable-Error` when the + signature or body is bad. +- Every delivery's QStash signature is checked against the URL you gave the dispatcher, not + `request.url`, so it works behind a proxy and a signature issued for another endpoint is refused. + Without signing keys the route throws instead of running anything unverified. +- When your handler throws, the task is not marked failed. Only the dispatcher marks it `failed`, + and only after QStash has stopped retrying. The failed message stays in the QStash DLQ. +- By default QStash tries 5 times with backoff `min(pow(3, retried) * 1000, 300000)`, about two + minutes in total, so a task survives a server restart. The free tier and the local dev server + allow at most 5 retries.
-Flow: the work running - -```mermaid -sequenceDiagram - participant D as Dispatcher (QStash/Workflow) - participant E as /api/execute - participant S as mcp-toolkit - participant H as Your handler - participant St as TaskStore - - D->>E: deliver the task (authenticated by the transport) - 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 -``` +Why plain tools and not the MCP Tasks extension? -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. +The 2026-07-28 spec defines a Tasks extension (`io.modelcontextprotocol/tasks`), where the +_client_ polls and the model spends no turns on it. But a server may only return a task to a client +that declared the extension, and as of October 2026 Claude Code, Codex, Cursor and OpenCode don't. +Plain tools cost the model a few polling calls but work everywhere today. The store and dispatcher +don't depend on the tools, so an adapter for the extension can serve the same records later.
-
-Flow: task_status and task_cancel - -```mermaid -sequenceDiagram - participant M as Model - participant S as mcp-toolkit - participant St as TaskStore - participant D as TaskDispatcher - - M->>S: task_status { taskId } - S->>St: get(taskId) - S->>S: owner matches? else "unknown task" - S-->>M: status, or the result once completed - - M->>S: task_cancel { taskId } - S->>St: settle(cancelled) - Note over St: refused if already terminal —
first terminal write wins - S->>D: cancel(dispatchId) - S-->>M: the cancelled task -``` - -
- -### Why tools, not the Tasks extension? - -The 2026-07-28 spec defines a Tasks extension (`io.modelcontextprotocol/tasks`): a `tools/call` -answers with `resultType: "task"`, and the client polls `tasks/get`. It is the right long-term -shape — the _client_ polls, so the model spends no turns on it. But a server must never return a -task to a client that has not declared the extension, and as of October 2026 none of the clients -people actually use do: not Claude Code, Codex, Cursor or OpenCode. Of the official SDKs only Rust -and C# implement it; TypeScript and Python have it on their roadmaps. - -Plain tools trade some polling turns for working everywhere today. The model is told to poll, gets -a suggested interval, and receives the result in the same shape it would have synchronously. - -The store and dispatcher do not care which surface sits on top. When clients declare the -extension, a native adapter can answer the same records over `tasks/get` / `tasks/cancel` for those -clients, and keep the tools for everyone else. - -## Events: webhooks that wake the agent - -[MCP Events](https://github.com/modelcontextprotocol/experimental-ext-triggers-events) is a draft -extension: the host subscribes to an event on your server and hands over a callback URL and a -signing secret; when the event happens, your server POSTs a signed envelope to that URL. ChatGPT -ships the webhook flavor ([OpenAI's guide](https://developers.openai.com/plugins/build/mcp-events)). - -This entry point implements the server side: `events/list`, `events/subscribe` and -`events/unsubscribe` on your MCP endpoint, the signed verification challenge, deterministic -subscription ids, expiry and refresh, Standard Webhooks signing, and durable retried delivery. - -### Usage +## Events ```ts // lib/events.ts import { createEventLayer } from "@upstash/mcp-toolkit/events"; import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; +import { principal } from "./auth"; export const events = createEventLayer({ store: new RedisSubscriptionStore(), delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), - // `secretKey` defaults to MCP_EVENTS_SECRET_KEY, which encrypts the hosts' signing secrets at - // rest. There is no built-in default: generate one with `openssl rand -base64 32`. - principal, // required: the same function as for tasks + principal, }); export const commentCreated = events.define("comment.created", { - description: "A new review comment was added to a document.", - input: z.object({ documentId: z.string() }), - payload: z.object({ documentId: z.string(), commentId: z.string(), text: z.string() }), - // Required. Runs when a host subscribes or refreshes, and again before every delivery. + description: "A new comment was added to a document.", + input: z.object({ documentId: z.string().optional() }), // what a subscriber may filter on + payload: z.object({ documentId: z.string(), text: z.string() }), + // May this user see comments on this document? authorize: (args, { principal }) => canRead(principal, args.documentId), }); ``` -Register it next to your tools, and serve the route QStash delivers to: - -```ts -// in createServer() -events.register(server); -``` +Call `events.register(server)` in `createServer()`, next to `tasks.register(server)`, and add the +route QStash delivers to: ```ts // app/api/events/route.ts @@ -414,535 +264,207 @@ import { events } from "../../lib/events"; export const POST = events.createDeliveryHandler(); ``` -Then emit from wherever the change happens — a route, a webhook from your own app, a job: - -```ts -await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); -``` - -`emit` is typed by the `payload` schema and validates against it. It finds every subscription whose -arguments match (here, everyone watching `doc_123`) and hands each one to QStash. Before each -delivery, `authorize` runs again for that subscriber, so someone who lost access to the document -stops getting its comments. The delivery route signs the -envelope with that subscriber's secret, POSTs it, and answers 500 when the callback failed so -QStash retries with backoff. Each attempt is signed fresh, and the event id stays the same, so the -host can drop duplicates. - -### Matching - -A subscription matches when every argument it gave equals the value emitted. Emitting -`{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of -`{ repo: "a", branch: "main" }`, and of `{}`. - -The values come from the payload fields named in the input schema, so when the payload carries -every input field, `emit(payload)` needs nothing else. When it doesn't, the type of `emit` makes -`args` required, and a runtime check refuses an emit that has no value for a required input field, -so a filter can never silently match nothing: - -```ts -const replyPosted = events.define("reply.posted", { - description: "A reply was posted in a thread.", - input: z.object({ threadId: z.string() }), - payload: z.object({ text: z.string() }), // no threadId here - authorize: (args, { principal }) => canReadThread(principal, args.threadId), -}); - -await replyPosted.emit({ text: "Agreed" }, { args: { threadId } }); // `args` is required -``` - -Pass `eventId` to make a repeated emit deduplicate, and `to` to narrow it to specific users: +Then emit from anywhere in your server code: ```ts -await commentCreated.emit(payload, { to: ["alice", "carol"], eventId: comment.id }); +await commentCreated.emit({ documentId: "doc_123", text: "Ship it?" }); ``` -For conditions exact matching cannot express, add `match: (args, payload) => boolean` to the -definition. - -### Users and subscriptions - -Three things decide who gets an event: the callback URL says **where**, the subscription's -arguments say **what**, and `authorize` says **who may**. - -- **The host routes to its user.** Each subscription carries a callback URL and signing secret the - host generated for it. ChatGPT sends a unique `connectors.api.openai.com/webhook/mcp-events/` - per monitor, so posting there reaches the right user. Your server never needs to know who the host - user is. -- **`authorize` is required.** It runs on every subscribe and refresh, with `{ principal, auth, - request }`, and again before every delivery with just the stored `principal` and `context`: the - token is never stored, so there is no `auth` behind a delivery. An event any authenticated caller - may hear says so with `authorize: () => true`. -- **`principal` can carry context.** Return `{ id, context }` instead of a string to store small, - non-secret data (an org id, a role) on the subscription. `authorize` gets it back as - `caller.context` at delivery time. It is a snapshot from subscribe time, so revocation checks - should query your own data with `principal`. -- **Personal events use `to`.** Some events belong to one user, like "your export finished". Mark - them `personal: true`, and every `emit` must say who with `to` (the type requires it, and so does - a runtime check). A subscription with no arguments then only hears about its own user's events. - `to` also works on ordinary events, to narrow one emit. -- **No anonymous subscriptions.** When `principal` throws, subscribe and unsubscribe - are refused with reason `not_authenticated`, before any challenge is sent. Only the subscriber - can unsubscribe. +Every subscription that matches the payload, and that `authorize` still allows for +`documentId: "doc_123"`, gets a signed webhook. QStash retries failed deliveries with backoff, and +the event id stays the same across retries so the host can drop duplicates. -```ts -const exportFinished = events.define("export.finished", { - description: "Your export finished.", - payload: z.object({ exportId: z.string(), url: z.string() }), - personal: true, - authorize: () => true, // `to` already limits it to the export's owner -}); +
+Matching and authorize -await exportFinished.emit({ exportId, url }, { to: userId }); -``` +Every `input` field must also be a `payload` field: the payload is the one place an event's values +come from, and the same values are used to route it and to authorize it. A subscription matches +when each argument it gave equals the payload's value. Emitting +`{ documentId: "doc_123", text }` reaches subscribers of `{ documentId: "doc_123" }` and of `{}`. -### `task.finished`: tasks that push instead of being polled +`authorize(args, caller)` runs twice: -```ts -import { createEventLayer, taskFinishedEvent } from "@upstash/mcp-toolkit/events"; +- **On every subscribe and refresh**, with the subscription's arguments and + `{ principal, phase: "subscribe", auth, request }`, before the callback is challenged or anything + is stored. A refusal is an error to the host. +- **Before every delivery**, with the _event's_ values for the input fields and + `{ principal, phase: "deliver" }`. So a subscriber who filtered on nothing is still checked + against each event's `documentId`, and revoked access stops the events. A refusal drops that + delivery; a throw (your database is down, say) makes QStash retry it. -const events = createEventLayer({ - /* … */ -}); -const taskFinished = taskFinishedEvent(events); +`() => true` lets every authenticated subscriber hear every matching event. -const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); -``` - -A host subscribes with no arguments to hear about every task its user starts, or with `taskId` for -one. The payload carries the status and, when it fits in the 256 KiB envelope, the result. -It is a personal event, emitted `to` the task's owner, so deliveries only go to their subscriptions. - -### What the layer checks for you - -- **The callback.** It must be `https` on a public host: `localhost`, single-label and `.internal` - names, private, loopback and link-local IPs, and credentials in the URL are refused, and - reserved and documentation ranges (including IPv4 embedded in IPv6) are refused, trailing dots - included, and redirects are never followed. Before storing a subscription the server POSTs a - signed challenge and requires it echoed back, reading at most 4 KB of the answer. Every failure - answers the same `-32015`, so a subscriber cannot probe your network; the detail goes to your - logs. The checks do not resolve DNS, so add egress filtering in production. Set - `allowInsecureCallbacks` for local development only. -- **The secret.** `whsec_` plus 24–64 base64 bytes, stored AES-256-GCM encrypted under - `secretKey`. A refresh with the same secret skips the challenge; a new one re-verifies. -- **Authorization.** `authorize` runs on every subscribe and refresh, and again before every - delivery. A delivery it refuses is dropped, not retried. -- **Lifetime.** The host's `ttlMs` is granted up to `defaults.maxTtlMs` (30 days); `refreshBefore` - tells it when to subscribe again. -- **Host answers.** `410` deletes the subscription, `413` and redirects drop the event, anything else - retries. - -### Who can subscribe today +Pass `{ eventId }` as the second argument to `emit` to deduplicate: emitting the same id twice +delivers once. -As of October 2026, ChatGPT is the only widely used host that subscribes to MCP Events (webhook -mode, in Work chats). Codex supports events only for OpenAI's own connectors, and Claude Code, -Cursor and OpenCode do not subscribe yet. The demo's Deploy Watch server has been tested end to end -with ChatGPT monitors. Poll and stream delivery modes in the draft are not -implemented here; `events/subscribe` refuses them. - -## Who can see what - -Every guarantee below starts from `principal`. It runs on the server, on every request, and reads -the `AuthInfo` your route verified (or the request, for session apps). The caller never supplies an -owner or a subscriber id: no tool argument, subscription argument or header is trusted for it. So -the guarantees are only as good as `principal`: it must return the user (the token's subject), not -`auth.clientId`, which every user of a host like ChatGPT shares. - -### Tasks: nobody can read or cancel another user's task - -1. **The owner is set by the server.** When a task tool is called, the toolkit runs `principal` - and stores the result as the task's `owner`. The model only sends the tool's own arguments, and - there is no way to pass an owner. A caller `principal` cannot identify is refused before - anything is stored. -2. **Every read checks it.** `task_status` and `task_cancel` run `principal` again, load the task, - and compare its `owner` to the caller. Anything else gets the same answer as an id that never - existed ("Unknown task"), so another user's id cannot even be confirmed to exist. -3. **Ids don't help.** Task ids are random UUIDs, and knowing one gets you nothing without being - its owner. -4. **There is no other way in.** No tool lists tasks. The execute endpoint only accepts deliveries - signed by QStash, and it runs the handler; it never returns a task to the caller. -5. **`task.finished` follows the same owner.** It is a personal event, emitted `to` the task's - owner, so a task's result only reaches that user's subscriptions. - -### Events: only users with access can subscribe - -On every `events/subscribe`: - -1. `principal` gives the subscriber. If it is `undefined`, the call is refused with - `not_authenticated`. -2. The arguments are validated against the event's `input` schema. -3. `authorize(args, { principal, context, phase: "subscribe", auth, request })` runs. It is - required on every event. If it says no, the call is refused with `not_authorized`. This happens - **before** the callback is challenged and before anything is stored, so a refused caller costs - one call and leaves nothing behind. -4. Only then is the callback verified and the subscription stored, with the subscriber's id in its - record and in its id. - -A refresh is a subscribe with the same arguments, so it goes through all four steps again. An -unsubscribe recomputes the subscription id from the caller's own principal, so a user can only ever -remove their own subscriptions. - -### Events: deliveries only reach users who still have access - -Subscribing was allowed once, but access changes. So the check runs again for every delivery: - -1. **`emit` picks candidates.** It finds the subscriptions whose arguments match, and, when `to` is - given, keeps only those users' subscriptions. A `personal` event cannot be emitted without `to`; - the type requires it, and so does a runtime check. `emit` is your server code, never something a - client calls. -2. **Each candidate is queued separately**, one QStash message per subscription. -3. **The delivery route checks again.** When QStash calls it, the route loads the subscription and - runs `authorize(args, { principal: subscriber, context, phase: "deliver" })`. Because this - happens at delivery time, not at `emit`, it also catches access removed between the two. - - **No:** the event is dropped. Nothing is sent, and QStash does not retry it. - - **Throws** (your database is down, say): the route answers 500 and QStash retries later, so a - temporary failure never turns into a delivery. - - **Yes:** the envelope is signed with that subscription's own secret and POSTed to that - subscription's own callback URL. - -At delivery there is no token or request (neither is ever stored), so `authorize` decides from the -stored subscriber id, the subscription's arguments and the optional `context`, by asking your own -data: "can this user still read this document?" Treat `context` as a snapshot from subscribe time; -revocation checks should look up the current state. - -### What this does not cover - -- **Your `authorize`.** The toolkit makes sure it runs; whether it is right is yours. An event with - `authorize: () => true` reaches every authenticated subscriber whose arguments match. -- **Field-level redaction.** `authorize` lets a whole event through or not. Every recipient of an - emit gets the same payload, so don't put data in it that only some of them may see; emit - separately, with `to`, instead. -- **The Redis and QStash credentials.** Anyone who can write the stored records can change an owner - or a callback URL. See [What lives in Redis](#what-lives-in-redis). - -## What lives in Redis - -Everything the toolkit keeps is in your Upstash Redis database, under two prefixes you can change -(`prefix` on each store). This section lists every key, what it is for, and what it means for -security and correctness. Treat the Redis credentials like any other production secret: whoever can -write these keys can change who owns a task or where an event goes. - -### Tasks: one hash per task - -`mcp:task:` is a hash with one field per task property, each value JSON-encoded: - -| Field | What it is | -| --- | --- | -| `taskId`, `name` | The task id, and the defined task (tool) name that picks the handler | -| `args` | The validated tool arguments, replayed into the handler on delivery | -| `owner` | The caller's id from `principal`, checked on every `task_status` / `task_cancel` | -| `status`, `statusMessage` | `working`, `completed`, `failed` or `cancelled`, and the progress line | -| `result` / `error` | The handler's tool result once `completed`, or the error once `failed` | -| `createdAt`, `lastUpdatedAt`, `ttlMs`, `pollIntervalMs` | Timing | -| `dispatchId` | The QStash message id or Workflow run id, so a cancel can stop pending deliveries | - -**Security** - -- `args` and `result` are **plain JSON**. Anyone who can read the database can read them, for as - long as the task lives. Keep secrets out of tool arguments and results, or use a short `ttlMs`. -- `owner` is what keeps tasks apart. A task is only ever answered to the caller whose `principal` - matches it; another caller's id reads as unknown. That is why `principal` must return the user - (the token's subject), not `auth.clientId`. -- The caller's token, `AuthInfo` and request are **never stored**. -- The owner is not part of the key. The task id is a random UUID (`crypto.randomUUID()`), so two - tasks never share a record, and the stored `owner` field is what every read checks. - -**Correctness** - -- **Durable before the reply.** The record is written before the tool answers with its id, because - the model may poll from another instance right away. Creation is one Lua script that writes the - hash and its expiry together, and only if the key is absent, so a record never exists without a - TTL and is never overwritten. -- **First terminal write wins.** Progress updates and terminal transitions go through one guarded - Lua script that refuses to touch a task that is already `completed`, `failed` or `cancelled`. A - completion that lands after a cancel cannot overwrite it, and a late progress line cannot - overwrite "Cancelled by client". -- **One field per property**, so a progress write and a cancel never clobber each other's fields. -- **TTL from creation.** `PEXPIRE` is set once, from `ttlMs` (5 minutes by default), and never - extended. An expired task reads as unknown, and its handler's `isCancelled()` returns true. With - `ttlMs: null` the record has no expiry and stays until you delete it. -- **Values are JSON-encoded on write**, so the client's automatic decoding is the exact inverse: a - status message of `"123"` comes back as a string. A Redis client built with - `automaticDeserialization: false` is not supported. - -### Events: one key per subscription, one index per filter - -`mcp-events:sub:` is a JSON string with the subscription: - -| Field | What it is | -| --- | --- | -| `id` | `sub_` + a hash of subscriber, callback URL, event and arguments | -| `event`, `args`, `argsKey` | The event name, the subscriber's filter, and its canonical JSON | -| `url` | The callback URL the host gave, which every delivery for this subscription goes to | -| `encryptedSecret` | The host's `whsec_` signing secret, AES-256-GCM encrypted under `secretKey` | -| `subscriber` | The caller's id from `principal` | -| `context` | Optional non-secret context `principal` returned, at most 4 KB | -| `createdAt`, `expiresAt` | Timing | - -`mcp-events:idx::` is a sorted set of subscription ids scored by expiry, -so an emit reads only the subscriptions its arguments can match. - -**Security** - -- **The signing secret is encrypted** with `secretKey` (`MCP_EVENTS_SECRET_KEY`), which is never - stored in Redis. A leaked database does not let anyone forge webhooks to the hosts. Rotating the - key makes stored secrets unreadable; those subscriptions stop receiving events until the host - refreshes and re-verifies. -- **No token is stored.** That is why `authorize` before a delivery only gets the stored - `subscriber` and `context`, never `auth`. Put only non-secret data (an org id, a role) in - `context`. -- **Callback URLs are visible** to anyone who can read the database. They are host-generated and - only accept requests signed with the encrypted secret. -- **Where vs who.** The callback URL says where a delivery goes; `subscriber` plus `authorize` say - who may receive it. One user connected through two hosts has two subscriptions with two URLs and - two secrets, so a delivery for one host is never sent to the other. -- A subscription is stored only after `authorize` allows it and the callback echoed a signed - challenge. Unsubscribing recomputes the id from the caller's own principal, so only the - subscriber can remove it. - -**Correctness** - -- **Expiry.** The subscription key expires with the subscription (`PX`), and the index is written in - the same Lua script, kept alive as long as its longest-lived member, with expired members pruned - on every write. Reads only take index entries scored in the future. -- **Deterministic ids.** Subscribing again with the same subscriber, URL, event and arguments - updates the same record (a refresh) instead of creating a duplicate. -- **Bounded lookups.** An emit reads one index per subset of its arguments (at most 256), then the - matching records, in requests of at most 1,000 commands. - -### Not in Redis, but worth knowing - -- **QStash messages.** A task delivery carries only `{ taskId }`. An event delivery carries the - subscription id and the full event envelope, including its payload, so the payload sits in QStash - (and its DLQ, if every retry fails) until it is delivered. -- **Deduplication keys.** Task dispatches dedupe on the task id (and a Workflow run is named after - it), and event deliveries on event id plus subscription id. -- **Never stored anywhere by the toolkit:** the caller's token or `AuthInfo`, the raw request, the - plaintext webhook secret, and `MCP_EVENTS_SECRET_KEY`. - -## Reference +
-The task interfaces - -```ts -interface TaskStore { - /** Create-if-absent, atomically. Returns the existing task when the id is taken, else null. */ - 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, first terminal write wins. `settled` is true only for the call that made the move. */ - settle(taskId: string, patch: TerminalTaskPatch): Promise<{ task: Task; settled: boolean } | null>; -} - -interface TaskDispatcher { - /** Idempotent per task id. */ - dispatch(task: Task): 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. +What the layer checks for you + +- **The caller first.** `events/subscribe` and `events/unsubscribe` resolve `principal` before + anything else, so an unidentified caller learns nothing about your events. A subscription's id is + a hash of subscriber, callback URL, event and arguments, so a user can only refresh or remove + their own. +- **The callback URL.** It must be `https` on a public host name. Refused: every IP literal, + `localhost`, single-label, `.local` and `.internal` names, and credentials in the URL. Redirects + are never followed. Before a subscription is stored, the server POSTs a signed challenge and + requires the host to echo it back. Every failure returns the same `-32015` error, so a subscriber + can't use it to probe your network (the details go to your logs). DNS is not resolved, so use + egress filtering in production. `allowInsecureCallbacks: true` turns these checks off. Use it in + local development only. +- **The signing secret.** It must be `whsec_` followed by 24 to 64 base64 bytes, and it is stored + encrypted with AES-256-GCM under `MCP_EVENTS_SECRET_KEY`, which must be base64 of at least 32 + random bytes. A refresh with the same secret skips the challenge. If you rotate the key, stored + subscriptions stop receiving events until the host refreshes them. +- **Lifetime.** A subscription lasts 7 days by default and 30 days at most. +- **Host answers.** `410` deletes the subscription, `413` and redirects drop the event, and any + other error is retried. +- **Not implemented:** the draft's poll and stream delivery modes (`events/subscribe` refuses + them) and event replay (`cursor` is always `null`).
-Options: tasks - -**`createTaskLayer`** - -| | | -| ------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `store`, `dispatcher` | Required. | -| `principal` | Required. `({ auth, request }) => string`, may be async — the caller's id; throw to refuse the call. | -| `defaults.ttlMs` | Retention window, `null` for unlimited. Default 5 min. | -| `defaults.pollIntervalMs` | Poll interval suggested to the model. Default 2s. | -| `toolNames` | Rename `task_status` / `task_cancel`, e.g. to namespace them. | -| `onSettle` | `(task) => void` — called once when a task completes, fails or is cancelled. Wire `taskFinishedEvent` here. | +Which hosts subscribe today -**`define(name, config, handler)` config** — `description`, `inputSchema`, plus optional `title`, `ttlMs`, -`pollIntervalMs`, `queuedMessage`, `completedMessage`. +As of October 2026, ChatGPT is the only widely used host that subscribes to MCP Events (webhook +mode, in Work chats; see [OpenAI's guide](https://developers.openai.com/plugins/build/mcp-events)). +Codex supports events only for OpenAI's own connectors. Claude Code, Cursor and OpenCode don't +subscribe yet. The spec draft is +[here](https://github.com/modelcontextprotocol/experimental-ext-triggers-events). -**`RedisTaskStore`** — `redis` (defaults to `Redis.fromEnv()`; `automaticDeserialization: false` -is not supported), `prefix`, `enableTelemetry`. +
-**Data at rest.** See [What lives in Redis](#what-lives-in-redis). +## Reference -**`QStashDispatcher`** — `url` required; `qstash`, `receiver`, `retries`, `retryDelay`, `headers`, -`enableTelemetry`. +
+Who can see what -**`WorkflowDispatcher`** — `url` required; `client`, `qstash`, `receiver`, `headers`, `retries`, -`enableTelemetry`. +**Tasks.** The server sets the owner from `principal`, and no tool argument can set it. +`task_status` and `task_cancel` only accept UUID task ids, and compare the stored owner with the +caller. For anyone else, the task looks exactly like an unknown id, so they can't even tell it +exists. Task ids are random UUIDs. No tool lists tasks, and the execute route only accepts signed +QStash deliveries and never returns a task. -**Signing keys are required.** Every delivery endpoint (`QStashDispatcher`, `WorkflowDispatcher`, -`QStashDelivery`) verifies QStash's signature with `receiver`, or with a `Receiver` built from -`QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY`. With neither, the endpoint throws on -its first request; it never runs a delivery unverified. This matters most for Workflow, whose own -`serve()` skips verification when the env vars are missing. +**Events.** See "Matching and `authorize`" above. The token is never stored, so the +delivery-time check gets no `auth`. -**Telemetry.** The Redis, QStash and Workflow clients the toolkit builds or receives get -`@upstash/mcp-toolkit@` appended to their `Upstash-Telemetry-Sdk` header, the same way -the other Upstash SDKs report. Opt out per backend with `enableTelemetry: false`, on the client -itself, or with `UPSTASH_DISABLE_TELEMETRY`. +**What this does not cover.** Whether your `authorize` is correct is up to you. Every recipient of +an emit gets the same payload, so don't put data in it that only some matching subscribers may see. +Anyone with write access to your Redis can change an owner or a callback URL.
-Exports: /tasks - -| Export | What it is | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `createTaskLayer(options)` | `{ define, register, createExecuteHandler, getTask, cancelTask }` | -| `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 writes | -| `SettleResult`, `PrincipalResolver` | What `settle` returns, and the type of `principal` | -| `Task`, `WireTask`, `TaskStatus`, `TaskError` | The record, and the subset the model sees | -| `isTerminal`, `TERMINAL_STATUSES`, `UnknownTaskError` | Status helpers and the store's error type | -| `DEFAULT_TOOL_NAMES`, `Caller`, `CallerAuth` | `{ status: "task_status", cancel: "task_cancel" }`, and what `principal` receives | -| `MemoryTaskStore`, `InlineTaskDispatcher` | Non-durable backends for tests | -| `@upstash/mcp-toolkit/upstash` | `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher`, `WorkflowContext` (type) | +What is stored in Redis -
+Treat the Redis credentials like any other production secret. -
-The event interfaces +**`mcp:task:`**: a hash with one JSON-encoded field per property: `taskId`, `name`, +`args`, `owner`, `status`, `statusMessage`, `result` / `error`, `createdAt`, `lastUpdatedAt`, +`ttlMs`, `pollIntervalMs`. -```ts -interface SubscriptionStore { - put(subscription: Subscription): Promise; - get(id: string): Promise; - /** Gets the index coordinates along with the id, so it needs no read first. */ - delete(subscription: Pick): Promise; - /** Every live subscription to `event` whose canonical arguments are one of `argsKeys`. */ - find(event: string, argsKeys: string[]): Promise; -} +- `args` and `result` are **plain JSON**. Keep secrets out of tool arguments and results, or use a + short `ttlMs`. +- The task is written with its TTL before the tool replies, because the model's next poll may + reach another instance. The TTL counts from creation and is never extended. +- Status changes go through one guarded Lua script, and the first final status wins, so a + completion can't overwrite a cancel. Each property is its own field, so a progress update and a + cancel never overwrite each other. +- A Redis client built with `automaticDeserialization: false` is not supported. -interface EventDelivery { - enqueue(jobs: DeliveryJob[]): Promise; - attach?(endpoints: { send(job: DeliveryJob): Promise }): void; - createDeliveryHandler?(): (request: Request) => Promise; -} -``` +**`mcp-events:sub:`**: the subscription (`event`, `args`, `url`, `encryptedSecret`, +`subscriber`, `createdAt`, `expiresAt`), expiring with it. **`mcp-events:idx:`**: a sorted +set of the event's subscription ids, scored by expiry. -`RedisSubscriptionStore` keeps one expiring key per subscription and a sorted set per -`(event, arguments)` scored by expiry, so an emit reads only what it can match, in batches of at -most 1,000 commands. -`MemorySubscriptionStore` + `InlineDelivery` (sends in-process, no retries) ship for tests. +**Not in Redis.** A task message in QStash carries only `{ taskId }`. An event message carries the +full payload, which stays in QStash (and in its DLQ, if every retry fails) until it is delivered. +The toolkit never stores the caller's token, the request, the plaintext webhook secret or +`MCP_EVENTS_SECRET_KEY`.
-Options: events - -**`createEventLayer`** +All options -| | | -| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `store`, `delivery` | Required. | -| `secretKey` | Encrypts stored signing secrets. Defaults to `MCP_EVENTS_SECRET_KEY`; required, no built-in default. | -| `principal` | Required. `({ auth, request }) => id \| { id, context }`, may be async — the subscriber; throw to refuse. | -| `defaults.ttlMs` / `defaults.maxTtlMs` | Granted lifetime when none is asked for (7 days), and the cap (30 days). | -| `allowInsecureCallbacks` | Accept `http://` and private hosts. Local development only. | -| `timeoutMs` | Per-POST timeout. Default 10s. | +**`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: +`defaults.ttlMs` (5 min) and `defaults.pollIntervalMs` (2s). -**`define` config** — `description`, `payload` and `authorize` (required), plus optional `title`, -`input`, `personal` and `match`. +**`tasks.define(name, config, handler)`**: `description` and `inputSchema` are required. Optional: +`title`, `completedMessage`. -**`RedisSubscriptionStore`** — `redis`, `prefix` (default `mcp-events:`), `enableTelemetry`. +**`createEventLayer`**: `store`, `delivery` and `principal` are required, and so is `secretKey` +unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `allowInsecureCallbacks`. -**`QStashDelivery`** — `url` required; `qstash`, `receiver`, `retries` (default 3), `retryDelay`, `headers`, -`enableTelemetry`. +**`events.define(name, config)`**: `description`, `payload` and `authorize` are required. +Optional: `title`, `input`. -
+**`RedisTaskStore`** / **`RedisSubscriptionStore`**: `redis` (defaults to one from env), `prefix` +(`mcp:task:` / `mcp-events:`), `enableTelemetry`. -
-Exports: /events - -| Export | What it is | -| --------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `createEventLayer(options)` | `{ define, register, createDeliveryHandler, list }` | -| `taskFinishedEvent(events)` | `{ event, onSettle }` — the bridge from tasks | -| `SubscriptionStore`, `EventDelivery`, `Subscription`, `EventEnvelope` | The two seams and the records | -| `signWebhook`, `verifyWebhook` | Standard Webhooks signing, and verification for writing a receiver | -| `callbackUrlProblem`, `decodeSecret`, `SecretBox` | The callback, secret and encryption checks the layer uses | -| `EventPayloadTooLargeError`, `CALLBACK_ENDPOINT_ERROR`, `MAX_PAYLOAD_BYTES` | Limits and errors | -| `AuthorizeCaller`, `Recipients`, `Principal`, `EmitOptions` | What `authorize` is told, what `to` takes, what `principal` returns | -| `MemorySubscriptionStore`, `InlineDelivery` | Non-durable backends for tests | -| `@upstash/mcp-toolkit/upstash` | `RedisSubscriptionStore`, `QStashDelivery` | - -
+**`QStashDispatcher`**: `url` is required. Optional: `qstash`, `receiver`, `retries` (5), +`retryDelay`, `enableTelemetry`. -## 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: +**`WorkflowDispatcher`**: `url` is required. Optional: `client`, `qstash`, `receiver`, `retries`, +`enableTelemetry`. -**`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, and **489** with `Upstash-NonRetryable-Error` for -a bad signature or an unusable body. QStash retries every other non-2xx, and a retry cannot fix -either of those. +**`QStashDelivery`**: `url` is required. Optional: `qstash`, `receiver`, `enableTelemetry`. +Deliveries are retried 3 times with QStash's backoff. -**`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. +`receiver` defaults to one built from the `QSTASH_*_SIGNING_KEY` variables. If neither is +available, the route throws on its first request instead of running a delivery unverified. -A dispatcher that runs work in-process — `InlineTaskDispatcher` — has no endpoint at all, and -`createExecuteHandler()` throws to say so. +**Telemetry.** The Redis and QStash clients get `@upstash/mcp-toolkit@` added to their +`Upstash-Telemetry-Sdk` header. Turn it off with `enableTelemetry: false` or +`UPSTASH_DISABLE_TELEMETRY`.
-Won't the model give up polling? +Custom backends -Sometimes, which is why every response says what to do next in words — "call `task_status` in -about 2s" — and `task_status` tells the model not to start the task again. Nothing is lost if the -model stops polling: the work finishes anyway, and the result stays readable until the task's TTL. +`/tasks` and `/events` don't import anything from Upstash. A backend implements one of these +interfaces (types exported from the same entry points): -
+```ts +interface TaskStore { + create(task: Task): Promise; // with its TTL + get(taskId: string): Promise; + update(taskId: string, patch: TaskPatch): Promise; // ignored once the task is final + settle(taskId: string, patch: TaskPatch & { status: TerminalTaskStatus }): Promise; // first final status wins +} -
-How long do retries last, and what if they run out? +interface TaskDispatcher { + dispatch(task: Task): Promise; // idempotent per task id + cancel(taskId: string): Promise; + createExecuteHandler(endpoints: TaskEndpoints): (request: Request) => Promise; +} -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. +interface SubscriptionStore { + put(subscription: Subscription): Promise; + get(id: string): Promise; + delete(subscription: { id: string; event: string }): Promise; + find(event: string): Promise; // every live subscription to the event +} -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. +interface EventDelivery { + enqueue(jobs: DeliveryJob[]): Promise; + createDeliveryHandler(send: SendJob): (request: Request) => Promise; +} +``` -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. +`/events` also exports `verifyWebhook` (Standard Webhooks) for writing a receiver.
-Is the task id a secret? +Not implemented yet -No. Every task is scoped to the `principal` that started it, so an id is useless to anyone but its -owner. Ids are also random (~122 bits for `randomUUID`), so they are not guessable either. +- `input_required`: a handler asking the user something partway through a task. +- Listing tasks. +- An adapter for the MCP Tasks extension. It would use the same store, and is waiting for clients + to support the extension. +- Event replay (`cursor`) and the draft's poll and stream delivery modes.
- -## Not implemented - -- `input_required` — a handler asking the user something mid-task. It would be the same shape: - write the question into the record, let the handler read the answer at a step boundary. -- Listing tasks. Deliberately absent: the model rarely needs it, and a list adds an index to keep - consistent with every expiry. -- A native Tasks-extension adapter. It sits on the same store; it waits on client support. -- Event replay (`cursor`), and the draft's poll and stream delivery modes. Subscriptions always - answer `cursor: null, truncated: false`. diff --git a/packages/mcp-toolkit/src/events/backends/memory.ts b/packages/mcp-toolkit/src/events/backends/memory.ts deleted file mode 100644 index 72fbe6e..0000000 --- a/packages/mcp-toolkit/src/events/backends/memory.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Single-process backends for tests and a first local run. {@link InlineDelivery} POSTs once, - * from the process that called `emit`, with no retries. - */ -import type { - DeliveryEndpoints, - DeliveryJob, - EventDelivery, - SendOutcome, - Subscription, - SubscriptionRef, - SubscriptionStore, -} from "../types.js"; - -/** An in-process {@link SubscriptionStore}. Not durable, not shared between instances. */ -export class MemorySubscriptionStore implements SubscriptionStore { - private readonly subscriptions = new Map(); - - async put(subscription: Subscription): Promise { - this.subscriptions.set(subscription.id, { ...subscription }); - } - - async get(id: string): Promise { - const subscription = this.live(id); - return subscription ? { ...subscription } : null; - } - - async delete({ id }: SubscriptionRef): Promise { - this.subscriptions.delete(id); - } - - async find(event: string, argsKeys: readonly string[]): Promise { - const keys = new Set(argsKeys); - return [...this.subscriptions.keys()] - .map((id) => this.live(id)) - .filter( - (sub): sub is Subscription => - sub !== undefined && sub.event === event && keys.has(sub.argsKey), - ) - .map((sub) => ({ ...sub })); - } - - /** Stands in for Redis' expiry. */ - private live(id: string): Subscription | undefined { - const subscription = this.subscriptions.get(id); - if (subscription && subscription.expiresAt <= Date.now()) { - this.subscriptions.delete(id); - return undefined; - } - return subscription; - } -} - -/** Sends each delivery from the calling process, once. A failure goes to `onOutcome`, then is forgotten. */ -export class InlineDelivery implements EventDelivery { - private endpoints: DeliveryEndpoints | undefined; - - constructor( - private readonly options: { onOutcome?: (job: DeliveryJob, outcome: SendOutcome) => void } = {}, - ) {} - - attach(endpoints: DeliveryEndpoints): void { - this.endpoints = endpoints; - } - - async enqueue(jobs: DeliveryJob[]): Promise { - const endpoints = this.endpoints; - if (!endpoints) { - throw new Error("InlineDelivery is not attached — pass it to createEventLayer()."); - } - await Promise.all( - jobs.map(async (job) => { - const outcome = await endpoints.send(job); - this.options.onOutcome?.(job, outcome); - }), - ); - } -} diff --git a/packages/mcp-toolkit/src/events/backends/qstash.test.ts b/packages/mcp-toolkit/src/events/backends/qstash.test.ts index 67cdc1c..c4d8c18 100644 --- a/packages/mcp-toolkit/src/events/backends/qstash.test.ts +++ b/packages/mcp-toolkit/src/events/backends/qstash.test.ts @@ -1,30 +1,33 @@ import { afterAll, describe, expect, it } from "vitest"; import { QStashDelivery, RedisSubscriptionStore } from "./qstash.js"; -import type { DeliveryJob, SendOutcome, Subscription } from "../types.js"; -import { canonicalJson } from "../core.js"; -import { cleanupKeys, hasRedisCreds, testRedis, uniquePrefix } from "../../test-support.js"; - -const makeSub = (overrides: Partial = {}): Subscription => { - const args = overrides.args ?? { repo: "upstash/agentkit" }; - return { - id: `sub_${Math.random().toString(36).slice(2, 10)}`, - event: "push", - args, - argsKey: canonicalJson(args), - url: "https://receiver.example.com/cb", - encryptedSecret: "v1.aaa.bbb", - subscriber: "123", - context: { org: "acme" }, - createdAt: new Date().toISOString(), - expiresAt: Date.now() + 60_000, - ...overrides, - }; -}; +import type { DeliveryJob, Subscription } from "../types.js"; +import { + cleanupKeys, + hasRedisCreds, + qstashRequest, + testReceiver, + testRedis, + uniquePrefix, +} from "../../test-support.js"; + +const makeSub = (overrides: Partial = {}): Subscription => ({ + id: `sub_${Math.random().toString(36).slice(2, 10)}`, + event: "push", + args: { repo: "upstash/agentkit" }, + url: "https://receiver.example.com/cb", + encryptedSecret: "v1.aaa.bbb", + subscriber: "123", + createdAt: new Date().toISOString(), + expiresAt: Date.now() + 60_000, + ...overrides, +}); describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { const redis = testRedis(); const prefix = uniquePrefix("events"); const store = new RedisSubscriptionStore({ redis, prefix }); + const subKey = (id: string) => `${prefix}sub:${id}`; + const indexKey = (event: string) => `${prefix}idx:${event}`; afterAll(async () => { await cleanupKeys(redis, prefix); @@ -40,44 +43,50 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { it("expires the record with the subscription", async () => { const sub = makeSub({ expiresAt: Date.now() + 30_000 }); await store.put(sub); - const pttl = await redis.pttl(store.subKey(sub.id)); + const pttl = await redis.pttl(subKey(sub.id)); expect(pttl).toBeGreaterThan(0); expect(pttl).toBeLessThanOrEqual(30_000); - expect(await redis.pttl(await store.indexKey(sub))).toBeGreaterThan(0); }); - it("finds subscriptions by any of several argument keys, and only live ones", async () => { + it("finds every live subscription to an event, and nothing else", async () => { const all = makeSub({ event: "find", args: {} }); const one = makeSub({ event: "find", args: { repo: "a" } }); - const other = makeSub({ event: "find", args: { repo: "b" } }); - for (const sub of [all, one, other]) await store.put(sub); + const elsewhere = makeSub({ event: "other", args: {} }); + const expired = makeSub({ event: "find" }); + for (const sub of [all, one, elsewhere]) await store.put(sub); + // Written live, then left in the index past its expiry. + await redis.zadd(indexKey("find"), { score: Date.now() - 1, member: expired.id }); - const found = await store.find("find", ["{}", canonicalJson({ repo: "a" })]); + const found = await store.find("find"); expect(found.map((s) => s.id).sort()).toEqual([all.id, one.id].sort()); - expect(await store.find("find", [])).toEqual([]); - expect(await store.find("other-event", ["{}"])).toEqual([]); + expect(await store.find("nothing")).toEqual([]); }); - it("reads a large match in bounded batches", async () => { - // 1,100 matching subscriptions is more than one MGET's worth. + it("reads a large event in bounded batches", async () => { + // 1,100 subscriptions is more than one MGET's worth. const subs = Array.from({ length: 1100 }, (_, i) => makeSub({ event: "popular", args: {}, subscriber: `member-${i}` }), ); for (let i = 0; i < subs.length; i += 100) { await Promise.all(subs.slice(i, i + 100).map((sub) => store.put(sub))); } - const found = await store.find("popular", ["{}"]); + const found = await store.find("popular"); expect(found).toHaveLength(1100); expect(new Set(found.map((s) => s.id))).toEqual(new Set(subs.map((s) => s.id))); }); - it("refreshing replaces the record and extends the index", async () => { + it("refreshing replaces the record and keeps one index entry", async () => { const sub = makeSub({ event: "refresh", expiresAt: Date.now() + 10_000 }); await store.put(sub); await store.put({ ...sub, expiresAt: Date.now() + 50_000 }); expect((await store.get(sub.id))?.expiresAt).toBeGreaterThan(Date.now() + 40_000); - expect(await store.find("refresh", [sub.argsKey])).toHaveLength(1); - expect(await redis.pttl(await store.indexKey(sub))).toBeGreaterThan(40_000); + expect(await store.find("refresh")).toHaveLength(1); + }); + + it("prunes expired index entries on the next write", async () => { + await redis.zadd(indexKey("prune"), { score: Date.now() - 1000, member: "sub_old" }); + await store.put(makeSub({ event: "prune" })); + expect(await redis.zscore(indexKey("prune"), "sub_old")).toBeNull(); }); it("deletes the record and its index entry", async () => { @@ -85,20 +94,14 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { await store.put(sub); await store.delete(sub); expect(await store.get(sub.id)).toBeNull(); - expect(await store.find("delete", [sub.argsKey])).toEqual([]); - expect(await redis.zcard(await store.indexKey(sub))).toBe(0); + expect(await store.find("delete")).toEqual([]); + expect(await redis.zcard(indexKey("delete"))).toBe(0); await store.delete(sub); // idempotent }); }); describe("QStashDelivery", () => { - const receiver = (accept: boolean) => - ({ - verify: async () => { - if (!accept) throw new Error("bad signature"); - return true; - }, - }) as unknown as ConstructorParameters[0]["receiver"]; + const URL = "https://example.com/api/events"; const job: DeliveryJob = { subscriptionId: "sub_1", @@ -111,49 +114,61 @@ describe("QStashDelivery", () => { }, }; - const attached = (outcome: SendOutcome, accept = true) => { + const connected = (done: boolean) => { const sent: DeliveryJob[] = []; - const delivery = new QStashDelivery({ - url: "https://example.com/api/events", - receiver: receiver(accept), + const delivery = new QStashDelivery({ url: URL, receiver: testReceiver() }); + const handler = delivery.createDeliveryHandler(async (j) => { + sent.push(j); + return done; }); - delivery.attach({ - send: async (j) => { - sent.push(j); - return outcome; - }, - }); - return { handler: delivery.createDeliveryHandler(), sent }; + return { handler, sent }; }; - const post = (body: unknown) => - new Request("https://internal.example/api/events", { + it("acknowledges a finished job with 200", async () => { + const { handler, sent } = connected(true); + expect((await handler(qstashRequest(URL, job))).status).toBe(200); + expect(sent).toEqual([job]); + }); + + it("answers 500 so QStash retries a failed callback", async () => { + const { handler } = connected(false); + expect((await handler(qstashRequest(URL, job))).status).toBe(500); + }); + + it("verifies against the published URL, so it works behind a proxy", async () => { + const { handler, sent } = connected(true); + const signed = qstashRequest(URL, job); + const proxied = new Request("http://10.0.0.5:3000/api/events", { method: "POST", - headers: { "upstash-signature": "sig" }, - body: JSON.stringify(body), + headers: signed.headers, + body: await signed.text(), }); - - it("acknowledges delivered, gone and dropped with 200", async () => { - for (const outcome of ["delivered", "gone", "dropped"] as const) { - const { handler, sent } = attached(outcome); - expect((await handler(post(job))).status).toBe(200); - expect(sent).toEqual([job]); - } + expect((await handler(proxied)).status).toBe(200); + expect(sent).toEqual([job]); }); - it("answers 500 so QStash retries a failed callback", async () => { - const { handler } = attached("retry"); - expect((await handler(post(job))).status).toBe(500); + it("refuses, as non-retryable, anything QStash did not sign for this endpoint", async () => { + const forged = [ + new Request(URL, { method: "POST", body: JSON.stringify(job) }), + qstashRequest(URL, job, { key: "stolen" }), + qstashRequest(URL, job, { sub: "https://example.com/api/execute" }), + qstashRequest(URL, job, { body: "aGFzaCBvZiBzb21ldGhpbmcgZWxzZQ" }), + qstashRequest(URL, job, { exp: Math.floor(Date.now() / 1000) - 600 }), + ]; + for (const request of forged) { + const { handler, sent } = connected(true); + const response = await handler(request); + expect(response.status).toBe(489); + expect(response.headers.get("Upstash-NonRetryable-Error")).toBe("true"); + expect(sent).toEqual([]); + } }); - it("rejects an unsigned or malformed delivery as non-retryable", async () => { - const unsigned = attached("delivered", false); - const refused = await unsigned.handler(post(job)); - expect(refused.status).toBe(489); - expect(refused.headers.get("Upstash-NonRetryable-Error")).toBe("true"); - expect(unsigned.sent).toEqual([]); - const { handler } = attached("delivered"); - expect((await handler(post({ nope: true }))).status).toBe(489); + it("refuses a signed body that is not a delivery job", async () => { + const { handler, sent } = connected(true); + expect((await handler(qstashRequest(URL, { nope: true }))).status).toBe(489); + expect((await handler(qstashRequest(URL, "not json"))).status).toBe(489); + expect(sent).toEqual([]); }); it("publishes one deduplicated message per subscription, in batches of 100", async () => { @@ -164,18 +179,14 @@ describe("QStashDelivery", () => { return []; }, } as unknown as ConstructorParameters[0]["qstash"]; - const delivery = new QStashDelivery({ - url: "https://example.com/api/events", - qstash, - retries: 5, - }); + const delivery = new QStashDelivery({ url: URL, qstash }); const jobs = Array.from({ length: 150 }, (_, i) => ({ ...job, subscriptionId: `sub_${i}` })); await delivery.enqueue(jobs); expect(batches.map((b) => b.length)).toEqual([100, 50]); expect(batches[0]?.[0]).toMatchObject({ - url: "https://example.com/api/events", + url: URL, body: jobs[0], - retries: 5, + retries: 3, deduplicationId: "evt_1_sub_0", }); }); diff --git a/packages/mcp-toolkit/src/events/backends/qstash.ts b/packages/mcp-toolkit/src/events/backends/qstash.ts index a765ef9..f193e05 100644 --- a/packages/mcp-toolkit/src/events/backends/qstash.ts +++ b/packages/mcp-toolkit/src/events/backends/qstash.ts @@ -7,24 +7,23 @@ import type { Client as QStashClient, Receiver } from "@upstash/qstash"; import { lazy, nonRetryable, + readQStashJson, resolveQStash, resolveReceiver, resolveRedis, } from "../../shared/clients.js"; -import { sha256Hex } from "../../shared/crypto.js"; import type { - DeliveryEndpoints, DeliveryJob, EventDelivery, + SendJob, Subscription, - SubscriptionRef, SubscriptionStore, } from "../types.js"; -export const DEFAULT_EVENTS_PREFIX = "mcp-events:"; - -/** The most commands one pipeline or `MGET` carries; `find` splits larger lookups. */ +/** The most keys one `MGET` reads; `find` splits larger lookups. */ const MAX_BATCH = 1000; +/** Delivery retries after the first failed attempt. */ +const DELIVERY_RETRIES = 3; export type RedisSubscriptionStoreConfig = { /** The Upstash Redis client. Defaults to one from `UPSTASH_REDIS_REST_URL` / `_TOKEN`. */ @@ -36,73 +35,49 @@ export type RedisSubscriptionStoreConfig = { }; /** - * Writes the record with its expiry and its index entry, and keeps the index alive as long as its - * longest-lived member. ARGV: record, expiresAt, id, now. - */ -const PUT_SCRIPT = `#!lua flags=allow-key-locking -local ttl = tonumber(ARGV[2]) - tonumber(ARGV[4]) -if ttl <= 0 then return 0 end -redis.call('SET', KEYS[1], ARGV[1], 'PX', ttl) -redis.call('ZADD', KEYS[2], ARGV[2], ARGV[3]) -redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', ARGV[4]) -if redis.call('PTTL', KEYS[2]) < ttl then redis.call('PEXPIRE', KEYS[2], ttl) end -return 1 -`; - -/** - * One key per subscription, expiring with it, plus a sorted set per `(event, argsKey)` scored by - * expiry. An emit reads only the index entries it can match. + * One key per subscription, expiring with it, plus one sorted set per event scored by expiry. An + * emit reads the event's live subscriptions, and the layer keeps those whose arguments match. */ export class RedisSubscriptionStore implements SubscriptionStore { private readonly prefix: string; private readonly redis: () => Redis; constructor(config: RedisSubscriptionStoreConfig = {}) { - this.prefix = config.prefix ?? DEFAULT_EVENTS_PREFIX; + this.prefix = config.prefix ?? "mcp-events:"; this.redis = lazy(() => resolveRedis("RedisSubscriptionStore", config.redis, config.enableTelemetry), ); } async put(subscription: Subscription): Promise { - await this.redis().eval( - PUT_SCRIPT, - [this.subKey(subscription.id), await this.indexKey(subscription)], - [ - JSON.stringify(subscription), - String(subscription.expiresAt), - subscription.id, - String(Date.now()), - ], - ); + const now = Date.now(); + const ttl = subscription.expiresAt - now; + if (ttl <= 0) return; + const index = this.indexKey(subscription.event); + await this.redis() + .multi() + .set(this.subKey(subscription.id), JSON.stringify(subscription), { px: ttl }) + .zadd(index, { score: subscription.expiresAt, member: subscription.id }) + .zremrangebyscore(index, "-inf", now) + .exec(); } async get(id: string): Promise { return parse(await this.redis().get(this.subKey(id))); } - async delete(subscription: SubscriptionRef): Promise { - const pipeline = this.redis().pipeline(); - pipeline.del(this.subKey(subscription.id)); - pipeline.zrem(await this.indexKey(subscription), subscription.id); - await pipeline.exec(); + async delete({ id, event }: Pick): Promise { + await this.redis().multi().del(this.subKey(id)).zrem(this.indexKey(event), id).exec(); } - async find(event: string, argsKeys: readonly string[]): Promise { - if (argsKeys.length === 0) return []; - const keys = await Promise.all(argsKeys.map((argsKey) => this.indexKey({ event, argsKey }))); - const now = Date.now(); - const ids = new Set(); - for (const batch of chunks(keys, MAX_BATCH)) { - const pipeline = this.redis().pipeline(); - for (const key of batch) pipeline.zrange(key, now, "+inf", { byScore: true }); - for (const id of (await pipeline.exec()).flat()) ids.add(String(id)); - } - // A popular event can match many subscriptions, so the reads are batched too. + async find(event: string): Promise { + const ids = await this.redis().zrange(this.indexKey(event), Date.now(), "+inf", { + byScore: true, + }); const found: Subscription[] = []; - for (const batch of chunks([...ids], MAX_BATCH)) { - const records = await this.redis().mget(...batch.map((id) => this.subKey(id))); - for (const record of records) { + for (let i = 0; i < ids.length; i += MAX_BATCH) { + const keys = ids.slice(i, i + MAX_BATCH).map((id) => this.subKey(String(id))); + for (const record of await this.redis().mget(...keys)) { const sub = parse(record); if (sub) found.push(sub); } @@ -110,23 +85,15 @@ export class RedisSubscriptionStore implements SubscriptionStore { return found; } - /** The key a subscription is stored under. */ - subKey(id: string): string { + private subKey(id: string): string { return `${this.prefix}sub:${id}`; } - /** The index key for one `(event, argsKey)`. Hashed, because arguments can be long. */ - async indexKey({ event, argsKey }: Pick): Promise { - return `${this.prefix}idx:${event}:${(await sha256Hex(argsKey)).slice(0, 24)}`; + private indexKey(event: string): string { + return `${this.prefix}idx:${event}`; } } -function chunks(items: T[], size: number): T[][] { - const out: T[][] = []; - for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); - return out; -} - function parse(raw: unknown): Subscription | null { if (raw === null || raw === undefined) return null; // @upstash/redis deserializes JSON values automatically unless told not to. @@ -144,12 +111,6 @@ export type QStashDeliveryConfig = { * way: without keys the endpoint refuses to serve. */ receiver?: Receiver; - /** Retries after the first failed attempt. Defaults to 3. */ - retries?: number; - /** QStash retry delay expression. Defaults to QStash's backoff. */ - retryDelay?: string; - /** Extra headers QStash forwards to your endpoint. */ - headers?: Record; /** Set `false` to skip this package's tag in the telemetry header. */ enableTelemetry?: boolean; }; @@ -159,7 +120,6 @@ export class QStashDelivery implements EventDelivery { private readonly config: QStashDeliveryConfig; private readonly qstash: () => QStashClient; private readonly receiver: () => Receiver; - private endpoints: DeliveryEndpoints | undefined; constructor(config: QStashDeliveryConfig) { this.config = config; @@ -169,19 +129,13 @@ export class QStashDelivery implements EventDelivery { this.receiver = lazy(() => resolveReceiver("QStashDelivery", config.receiver)); } - attach(endpoints: DeliveryEndpoints): void { - this.endpoints = endpoints; - } - async enqueue(jobs: DeliveryJob[]): Promise { for (let i = 0; i < jobs.length; i += 100) { await this.qstash().batchJSON( jobs.slice(i, i + 100).map((job) => ({ url: this.config.url, body: job, - retries: this.config.retries ?? 3, - ...(this.config.retryDelay ? { retryDelay: this.config.retryDelay } : {}), - ...(this.config.headers ? { headers: this.config.headers } : {}), + retries: DELIVERY_RETRIES, // Emitting the same event id twice must not POST twice. QStash refuses ':' in ids. deduplicationId: `${job.envelope.eventId}_${job.subscriptionId}`.replace(/[^\w.-]/g, "-"), })), @@ -196,35 +150,20 @@ export class QStashDelivery implements EventDelivery { * - **500**: the callback failed; QStash retries. * - **489** with `Upstash-NonRetryable-Error`: bad signature or body, never retried. */ - createDeliveryHandler(): (request: Request) => Promise { + createDeliveryHandler(send: SendJob): (request: Request) => Promise { return async (request: Request): Promise => { - const endpoints = this.endpoints; - if (!endpoints) { - throw new Error("QStashDelivery is not attached — pass it to createEventLayer()."); - } - // Outside the try: missing signing keys are a configuration error, not a bad signature. - const receiver = this.receiver(); - const body = await request.text(); - try { - await receiver.verify({ - signature: request.headers.get("upstash-signature") ?? "", - body, - url: this.config.url, - }); - } catch { - return nonRetryable("invalid signature"); - } - let job: DeliveryJob; - try { - job = JSON.parse(body) as DeliveryJob; - } catch { - return nonRetryable("malformed body"); - } - if (!job?.subscriptionId || !job.envelope) return nonRetryable("malformed body"); - const outcome = await endpoints.send(job); - return outcome === "retry" - ? new Response("callback failed", { status: 500 }) - : new Response(outcome); + // Outside any try: missing signing keys are a configuration error, not a bad signature. + const job = await readQStashJson(request, this.receiver(), this.config.url); + if (job instanceof Response) return job; + if (!isDeliveryJob(job)) return nonRetryable("malformed body"); + return (await send(job)) + ? new Response("done") + : new Response("callback failed", { status: 500 }); }; } } + +function isDeliveryJob(value: unknown): value is DeliveryJob { + const job = value as Partial | null; + return typeof job?.subscriptionId === "string" && typeof job.envelope === "object"; +} diff --git a/packages/mcp-toolkit/src/events/core.test.ts b/packages/mcp-toolkit/src/events/core.test.ts index 65c551f..a5ae8e5 100644 --- a/packages/mcp-toolkit/src/events/core.test.ts +++ b/packages/mcp-toolkit/src/events/core.test.ts @@ -1,20 +1,21 @@ /** - * The events runtime, exercised through a real `McpServer` over JSON-RPC. The "host" is a fake - * `fetch` that plays the callback receiver: it answers the verification challenge and checks the - * Standard Webhooks signature on every POST, the way ChatGPT's receiver does. + * The events runtime, exercised through a real `McpServer` over JSON-RPC. The "host" is a stubbed + * global `fetch` that plays the callback receiver: it answers the verification challenge and + * checks the Standard Webhooks signature on every POST, the way ChatGPT's receiver does. */ import { randomBytes } from "node:crypto"; import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import * as z from "zod"; -import { createTaskLayer } from "../tasks/core.js"; -import { InlineTaskDispatcher, MemoryTaskStore } from "../tasks/backends/memory.js"; -import { createEventLayer, matchKeys, type EventLayerOptions } from "./core.js"; -import { InlineDelivery, MemorySubscriptionStore } from "./backends/memory.js"; -import { taskFinishedEvent } from "./tasks.js"; -import { callbackUrlProblem, verifyWebhook } from "./webhooks.js"; -import type { EventEnvelope, SendOutcome } from "./types.js"; -import { userIdOf } from "../test-support.js"; +import { createEventLayer, type EventLayerOptions } from "./core.js"; +import { SecretBox, callbackUrlProblem, verifyWebhook } from "./webhooks.js"; +import type { EventEnvelope } from "./types.js"; +import { + InlineDelivery, + MemorySubscriptionStore, + TEST_SECRET_KEY, + userIdOf, +} from "../test-support.js"; const PROTOCOL_VERSION = "2026-07-28"; const newSecret = () => `whsec_${randomBytes(32).toString("base64")}`; @@ -68,7 +69,6 @@ function request(method: string, params: Record, id: number): R "mcp-protocol-version": PROTOCOL_VERSION, "mcp-method": method, }; - if (typeof params.name === "string" && method === "tools/call") headers["mcp-name"] = params.name; return new Request("http://localhost/mcp", { method: "POST", headers, @@ -84,31 +84,46 @@ const as = (given?: string | null) => { : undefined; }; -function setup(options: Partial & { respond?: Respond } = {}) { - const { respond, ...layerOptions } = options; +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function setup( + options: Partial & { respond?: Respond; fetch?: typeof fetch } = {}, +) { + const { respond, fetch: fetchOverride, ...layerOptions } = options; const secrets = new Map(); const receiver = host(secrets, respond); - const outcomes: SendOutcome[] = []; + vi.stubGlobal("fetch", fetchOverride ?? receiver.fetch); const store = new MemorySubscriptionStore(); + const delivery = new InlineDelivery(); // Who may read which document. Documents not listed are readable by everyone, except secret-doc. const acl = new Map>(); - const canRead = (user: string, documentId: string) => - documentId !== "secret-doc" && (acl.get(documentId)?.has(user) ?? true); + const authorized: { args: unknown; phase: string; principal: string }[] = []; + const canRead = (user: string, documentId: string | undefined) => + documentId !== "secret-doc" && + (documentId === undefined || (acl.get(documentId)?.has(user) ?? true)); const events = createEventLayer({ store, - delivery: new InlineDelivery({ onOutcome: (_job, outcome) => outcomes.push(outcome) }), - secretKey: "test-key", + delivery, + secretKey: TEST_SECRET_KEY, principal: userIdOf, - fetch: receiver.fetch, ...layerOptions, }); const commentCreated = events.define("comment.created", { description: "A new comment on a document.", - input: z.object({ documentId: z.string(), author: z.string().optional() }), + input: z.object({ + documentId: z.string().optional(), + author: z.string().optional(), + }), payload: z.object({ documentId: z.string(), author: z.string(), text: z.string() }), - authorize: (args, { principal }) => canRead(principal, args.documentId), + authorize: (args, { principal, phase }) => { + authorized.push({ args, phase, principal }); + return canRead(principal, args.documentId); + }, }); + events.createDeliveryHandler(); // connects the inline delivery, as serving the route does const handler = createMcpHandler(() => { const server = new McpServer({ name: "test", version: "1.0.0" }); @@ -152,7 +167,27 @@ function setup(options: Partial & { respond?: Respond } = {}) return { ...response, url, secret }; }; - return { events, store, rpc, subscribe, receiver, outcomes, commentCreated, acl }; + const unsubscribe = (args: Record, url: string, user?: string | null) => + rpc( + "events/unsubscribe", + { name: "comment.created", arguments: args, delivery: { mode: "webhook", url } }, + user, + ); + + const done = () => delivery.outcomes.map((o) => o.done); + + return { + events, + store, + rpc, + subscribe, + unsubscribe, + receiver, + done, + commentCreated, + acl, + authorized, + }; } describe("events/list and capability", () => { @@ -167,7 +202,7 @@ describe("events/list and capability", () => { name: "comment.created", description: "A new comment on a document.", delivery: ["webhook"], - inputSchema: { type: "object", required: ["documentId"] }, + inputSchema: { type: "object" }, payloadSchema: { type: "object" }, }); expect(list.result?.nextCursor).toBeNull(); @@ -217,27 +252,29 @@ describe("events/subscribe", () => { const { subscribe, store } = setup({ respond: () => Response.json({ challenge: "nope" }) }); const response = await subscribe({ documentId: "doc_1" }); expect(response.error?.code).toBe(-32015); - expect(await store.find("comment.created", ['{"documentId":"doc_1"}'])).toHaveLength(0); + expect(await store.find("comment.created")).toHaveLength(0); }); it("gives the same error whatever went wrong, so the network behind it cannot be probed", async () => { - const errors = await Promise.all( - [ - setup({ - fetch: (async () => { - throw new TypeError("fetch failed"); - }) as typeof fetch, - }), - setup({ - fetch: (async () => { - throw Object.assign(new Error("timed out"), { name: "TimeoutError" }); - }) as typeof fetch, - }), - setup({ respond: () => new Response("no", { status: 500 }) }), - setup({ respond: () => new Response(null, { status: 302 }) }), - setup({ respond: () => Response.json({ challenge: "nope" }) }), - ].map(async ({ subscribe }) => (await subscribe({ documentId: "d" })).error), - ); + const outcomes = [ + { + fetch: (async () => { + throw new TypeError("fetch failed"); + }) as typeof fetch, + }, + { + fetch: (async () => { + throw Object.assign(new Error("timed out"), { name: "TimeoutError" }); + }) as typeof fetch, + }, + { respond: () => new Response("no", { status: 500 }) }, + { respond: () => new Response(null, { status: 302 }) }, + { respond: () => Response.json({ challenge: "nope" }) }, + ]; + const errors = []; + for (const options of outcomes) { + errors.push((await setup(options).subscribe({ documentId: "d" })).error); + } for (const error of errors) { expect(error).toEqual(errors[0]); expect(error?.code).toBe(-32015); @@ -253,7 +290,7 @@ describe("events/subscribe", () => { const { subscribe, rpc } = setup(); const reason = async (p: ReturnType) => (await p).error?.data?.reason; expect(await reason(subscribe({ documentId: "d" }, { name: "nope" }))).toBe("unknown_event"); - expect(await reason(subscribe({}))).toBe("invalid_arguments"); + expect(await reason(subscribe({ documentId: 42 }))).toBe("invalid_arguments"); expect(await reason(subscribe({ documentId: "d" }, { secret: "whsec_c2hvcnQ=" }))).toBe( "invalid_secret", ); @@ -272,16 +309,32 @@ describe("events/subscribe", () => { expect(poll.error?.data?.reason).toBe("unsupported_delivery_mode"); }); - it("grants the requested ttl, capped at the maximum", async () => { - const { subscribe, store } = setup({ defaults: { maxTtlMs: 60_000 } }); + it("refuses the subscribe, and sends no challenge, when authorize throws", async () => { + const { events, subscribe, receiver } = setup(); + events.define("flaky", { + description: "d", + payload: z.object({}), + authorize: () => { + throw new Error("permission service is down"); + }, + }); + const result = await subscribe({}, { name: "flaky" }); + expect(result.error).toBeDefined(); + expect(result.result).toBeUndefined(); + expect(receiver.received).toHaveLength(0); + }); + + it("grants the requested ttl, capped at 30 days", async () => { + const { subscribe, store } = setup(); + const day = 24 * 60 * 60 * 1000; const short = await subscribe({ documentId: "a" }, { ttlMs: 10_000 }); - const long = await subscribe({ documentId: "b" }, { ttlMs: 10 ** 10 }); + const long = await subscribe({ documentId: "b" }, { ttlMs: 10 ** 12 }); const unlimited = await subscribe({ documentId: "c" }, { ttlMs: null }); const ttlOf = async (id: unknown) => (await store.get(String(id)))!.expiresAt - Date.now(); expect(await ttlOf(short.result?.id)).toBeLessThanOrEqual(10_000); - expect(await ttlOf(long.result?.id)).toBeLessThanOrEqual(60_000); - expect(await ttlOf(long.result?.id)).toBeGreaterThan(50_000); - expect(await ttlOf(unlimited.result?.id)).toBeGreaterThan(50_000); + expect(await ttlOf(long.result?.id)).toBeLessThanOrEqual(30 * day); + expect(await ttlOf(long.result?.id)).toBeGreaterThan(29 * day); + expect(await ttlOf(unlimited.result?.id)).toBeGreaterThan(29 * day); }); }); @@ -294,7 +347,6 @@ describe("emit", () => { await subscribe({ documentId: "doc_2" }); const result = await commentCreated.emit({ documentId: "doc_1", author: "alice", text: "hi" }); - expect(result.matched).toBe(2); const deliveries = receiver.deliveries(); expect(deliveries.map((d) => d.url).sort()).toEqual([all.url, byAlice.url].sort()); for (const delivery of deliveries) { @@ -309,31 +361,15 @@ describe("emit", () => { } }); - it("matches on explicit args and narrows to the users in `to`", async () => { + it("uses a given eventId, so a repeated emit can be deduplicated", async () => { const { subscribe, commentCreated, receiver } = setup(); - await subscribe({ documentId: "doc_1" }, { user: "alice" }); - const bob = await subscribe({ documentId: "doc_1" }, { user: "bob" }); + await subscribe({ documentId: "d" }); const result = await commentCreated.emit( - { documentId: "ignored", author: "x", text: "y" }, - { args: { documentId: "doc_1" }, to: "bob" }, + { documentId: "d", author: "a", text: "x" }, + { eventId: "deploy_42" }, ); - expect(result.matched).toBe(1); - expect(receiver.deliveries()[0]?.url).toBe(bob.url); - }); - - it("reaches every matching subscriber without `to`, and only the named ones with it", async () => { - const { subscribe, commentCreated, receiver } = setup(); - const alice = await subscribe({ documentId: "doc_1" }, { user: "alice" }); - const bob = await subscribe({ documentId: "doc_1" }, { user: "bob" }); - const payload = { documentId: "doc_1", author: "x", text: "y" }; - const urls = () => receiver.deliveries().map((d) => d.url); - - expect((await commentCreated.emit(payload)).matched).toBe(2); - expect(urls().sort()).toEqual([alice.url, bob.url].sort()); - expect((await commentCreated.emit(payload, { to: "alice" })).matched).toBe(1); - expect(urls().at(-1)).toBe(alice.url); - expect((await commentCreated.emit(payload, { to: ["mallory"] })).matched).toBe(0); - expect((await commentCreated.emit(payload, { to: [] })).matched).toBe(0); + expect(result.eventId).toBe("deploy_42"); + expect(receiver.deliveries()[0]?.headers.get("webhook-id")).toBe("deploy_42"); }); it("rejects an invalid payload and an oversized one", async () => { @@ -344,42 +380,31 @@ describe("emit", () => { ).rejects.toThrow(/limit/); }); - it("deletes the subscription on 410 and does not retry 413", async () => { + it("deletes the subscription on 410, drops 413 and redirects, and retries the rest", async () => { let status = 410; - const { subscribe, commentCreated, outcomes, store } = setup({ + const { subscribe, commentCreated, done, store } = setup({ respond: (r) => (r.body.type === "verification" ? undefined : new Response(null, { status })), }); const sub = await subscribe({ documentId: "d" }); await commentCreated.emit({ documentId: "d", author: "a", text: "1" }); - expect(outcomes).toEqual(["gone"]); + expect(done()).toEqual([true]); expect(await store.get(String(sub.result?.id))).toBeNull(); - status = 413; await subscribe({ documentId: "d" }); - await commentCreated.emit({ documentId: "d", author: "a", text: "2" }); - status = 503; - await commentCreated.emit({ documentId: "d", author: "a", text: "3" }); + for (const next of [413, 503, 307, 400]) { + status = next; + await commentCreated.emit({ documentId: "d", author: "a", text: String(next) }); + } // A redirect is never followed, so retrying it cannot help either. - status = 307; - await commentCreated.emit({ documentId: "d", author: "a", text: "4" }); - expect(outcomes).toEqual(["gone", "dropped", "retry", "dropped"]); + expect(done()).toEqual([true, true, false, true, false]); }); it("stops delivering after unsubscribe", async () => { - const { subscribe, rpc, commentCreated } = setup(); + const { subscribe, unsubscribe, commentCreated, receiver } = setup(); const sub = await subscribe({ documentId: "d" }, { user: "alice" }); - const response = await rpc( - "events/unsubscribe", - { - name: "comment.created", - arguments: { documentId: "d" }, - delivery: { mode: "webhook", url: sub.url }, - }, - "alice", - ); - expect(response.result).toBeDefined(); - const after = await commentCreated.emit({ documentId: "d", author: "a", text: "x" }); - expect(after.matched).toBe(0); + expect((await unsubscribe({ documentId: "d" }, sub.url, "alice")).result).toBeDefined(); + await commentCreated.emit({ documentId: "d", author: "a", text: "x" }); + expect(receiver.deliveries()).toHaveLength(0); }); it("keeps secrets encrypted at rest", async () => { @@ -411,25 +436,51 @@ describe("access across users", () => { const mallory = await subscribe({ documentId: "doc_shared" }, { user: "mallory" }); expect(mallory.error?.data?.reason).toBe("not_authorized"); expect(receiver.received).toHaveLength(challengesBefore); - expect(await store.find("comment.created", ['{"documentId":"doc_shared"}'])).toHaveLength(1); + expect(await store.find("comment.created")).toHaveLength(1); const carol = await subscribe({ documentId: "doc_shared" }, { user: "carol" }); expect(carol.error).toBeUndefined(); // One emit, no recipients named: both readers hear it, each on their own signed callback. const result = await commentCreated.emit(comment); - expect(result.matched).toBe(2); const deliveries = receiver.deliveries(); expect(deliveries.map((d) => d.url).sort()).toEqual([alice.url, carol.url].sort()); for (const delivery of deliveries) { expect(delivery.valid).toBe(true); expect(delivery.body).toMatchObject({ eventId: result.eventId, data: comment }); } - expect(deliveries.some((d) => d.url === mallory.url)).toBe(false); + }); + + it("checks a subscriber without a filter against each event's own values", async () => { + // The hazard this guards: a subscription with no arguments matches every emit, so checking + // only its (empty) arguments at delivery would hand mallory comments on documents she cannot + // read. authorize is asked about the event's documentId instead. + const { subscribe, commentCreated, receiver, authorized } = shared(); + const mallory = await subscribe({}, { user: "mallory" }); + expect(mallory.error).toBeUndefined(); + + await commentCreated.emit(comment); + await commentCreated.emit({ documentId: "doc_public", author: "dave", text: "Hello" }); + + expect(receiver.deliveries().map((d) => (d.body.data as { text: string }).text)).toEqual([ + "Hello", + ]); + expect(authorized.filter((a) => a.phase === "deliver")).toEqual([ + { + phase: "deliver", + principal: "mallory", + args: { documentId: "doc_shared", author: "dave" }, + }, + { + phase: "deliver", + principal: "mallory", + args: { documentId: "doc_public", author: "dave" }, + }, + ]); }); it("stops delivering to a subscriber whose access was revoked after subscribing", async () => { - const { subscribe, commentCreated, receiver, outcomes, acl } = shared(); + const { subscribe, commentCreated, receiver, done, acl } = shared(); const alice = await subscribe({ documentId: "doc_shared" }, { user: "alice" }); await subscribe({ documentId: "doc_shared" }, { user: "carol" }); @@ -437,65 +488,66 @@ describe("access across users", () => { await commentCreated.emit(comment); expect(receiver.deliveries().map((d) => d.url)).toEqual([alice.url]); - expect(outcomes.sort()).toEqual(["delivered", "dropped"]); + // A refusal is final: retrying would be refused again. + expect(done()).toEqual([true, true]); }); - it("hands authorize the principal's context at subscribe and again at delivery", async () => { - const seen: { phase: string; principal: string; org: unknown; hasAuth: boolean }[] = []; - const { events, subscribe, receiver } = setup({ - principal: (caller) => { - const userId = userIdOf(caller); - return { id: userId, context: { org: `org-of-${userId}` } }; + it("retries a delivery, rather than dropping it, when authorize throws", async () => { + const { events, subscribe, receiver, done } = setup(); + let down = true; + const memo = events.define("memo.posted", { + description: "d", + payload: z.object({ text: z.string() }), + authorize: (_args, { phase }) => { + if (phase === "deliver" && down) throw new Error("permission service is down"); + return true; }, }); + await subscribe({}, { name: "memo.posted" }); + await memo.emit({ text: "hi" }); + expect(done()).toEqual([false]); + expect(receiver.deliveries()).toHaveLength(0); + down = false; + }); + + it("gives authorize the verified auth at subscribe time only", async () => { + const seen: { phase: string; hasAuth: boolean }[] = []; + const { events, subscribe } = setup(); const memo = events.define("memo.posted", { - description: "A memo was posted to an org.", - input: z.object({ org: z.string() }), - payload: z.object({ org: z.string(), text: z.string() }), - authorize: ({ org }, caller) => { - seen.push({ - phase: caller.phase, - principal: caller.principal, - org: caller.context.org, - hasAuth: caller.auth !== undefined, - }); - return caller.context.org === org; + description: "d", + payload: z.object({ text: z.string() }), + authorize: (_args, caller) => { + seen.push({ phase: caller.phase, hasAuth: caller.auth !== undefined }); + return true; }, }); - - const alice = await subscribe({ org: "org-of-alice" }, { user: "alice", name: "memo.posted" }); - const bob = await subscribe({ org: "org-of-alice" }, { user: "bob", name: "memo.posted" }); - expect(alice.error).toBeUndefined(); - expect(bob.error?.data?.reason).toBe("not_authorized"); - - await memo.emit({ org: "org-of-alice", text: "Offsite on Friday" }); - expect(receiver.deliveries().map((d) => d.url)).toEqual([alice.url]); + await subscribe({}, { name: "memo.posted" }); + await memo.emit({ text: "hi" }); // The token is never stored, so only the subscribe-time check sees `auth`. expect(seen).toEqual([ - { phase: "subscribe", principal: "alice", org: "org-of-alice", hasAuth: true }, - { phase: "subscribe", principal: "bob", org: "org-of-bob", hasAuth: true }, - { phase: "deliver", principal: "alice", org: "org-of-alice", hasAuth: false }, + { phase: "subscribe", hasAuth: true }, + { phase: "deliver", hasAuth: false }, ]); }); - it("sends a personal event only to the users in `to`, and requires `to`", async () => { - const { events, subscribe, receiver } = setup(); - const exportFinished = events.define("export.finished", { - description: "Your export finished.", - payload: z.object({ exportId: z.string() }), - personal: true, - authorize: () => true, - }); - const alice = await subscribe({}, { user: "alice", name: "export.finished" }); - await subscribe({}, { user: "bob", name: "export.finished" }); - - // @ts-expect-error `to` is required for a personal event - const withoutTo = () => exportFinished.emit({ exportId: "e1" }); - await expect(withoutTo()).rejects.toThrow(/personal/); - - expect((await exportFinished.emit({ exportId: "e1" }, { to: "alice" })).matched).toBe(1); + it("never lets one user unsubscribe another's subscription", async () => { + const { subscribe, unsubscribe, commentCreated, receiver } = setup(); + const alice = await subscribe({ documentId: "d" }, { user: "alice" }); + // mallory knows the event, the arguments and even the callback URL. + expect((await unsubscribe({ documentId: "d" }, alice.url, "mallory")).result).toBeDefined(); + await commentCreated.emit({ documentId: "d", author: "a", text: "still here" }); expect(receiver.deliveries().map((d) => d.url)).toEqual([alice.url]); }); + + it("never lets one user overwrite another's subscription by re-subscribing", async () => { + const { subscribe, commentCreated, receiver, store } = setup(); + const alice = await subscribe({ documentId: "d" }, { user: "alice" }); + const mallory = await subscribe({ documentId: "d" }, { user: "mallory", url: alice.url }); + expect(mallory.result?.id).not.toBe(alice.result?.id); + expect((await store.get(String(alice.result?.id)))?.subscriber).toBe("alice"); + await commentCreated.emit({ documentId: "d", author: "a", text: "x" }); + expect(receiver.deliveries()).toHaveLength(2); + }); }); describe("define", () => { @@ -506,55 +558,69 @@ describe("define", () => { ).toThrow(/authorize/); }); - it("requires args when the payload does not carry every input field", async () => { - const { events, subscribe, receiver } = setup(); - const replied = events.define("reply.posted", { - description: "A reply was posted in a thread.", - input: z.object({ threadId: z.string() }), - payload: z.object({ text: z.string() }), // no threadId in the payload - authorize: () => true, - }); - const sub = await subscribe({ threadId: "t1" }, { name: "reply.posted" }); - - // @ts-expect-error args is required: the payload has no threadId to match on - const withoutArgs = () => replied.emit({ text: "hi" }); - await expect(withoutArgs()).rejects.toThrow(/threadId/); - - expect((await replied.emit({ text: "hi" }, { args: { threadId: "t1" } })).matched).toBe(1); - expect(receiver.deliveries().map((d) => d.url)).toEqual([sub.url]); + it("requires every input field to be a payload field, so emits are routed by the payload", () => { + const { events } = setup(); + expect(() => + events.define("reply.posted", { + description: "A reply was posted in a thread.", + input: z.object({ threadId: z.string() }), + payload: z.object({ text: z.string() }), // no threadId in the payload + authorize: () => true, + }), + ).toThrow(/threadId.*payload/); }); - it("derives the match values from the payload when it carries every input field", async () => { + it("routes an emit by the payload's values", async () => { const { subscribe, commentCreated, receiver } = setup(); const sub = await subscribe({ documentId: "doc_9" }); - // No options at all: documentId comes from the payload. - expect( - (await commentCreated.emit({ documentId: "doc_9", author: "a", text: "t" })).matched, - ).toBe(1); - expect(receiver.deliveries()[0]?.url).toBe(sub.url); + await subscribe({ documentId: "doc_8" }); + await commentCreated.emit({ documentId: "doc_9", author: "a", text: "t" }); + expect(receiver.deliveries().map((d) => d.url)).toEqual([sub.url]); }); }); describe("principal", () => { it("refuses to subscribe or unsubscribe a caller it cannot identify", async () => { - const { subscribe, rpc, receiver } = setup(); + const { subscribe, unsubscribe, receiver } = setup(); const anonymous = await subscribe({ documentId: "d" }, { user: null }); expect(anonymous.error?.data?.reason).toBe("not_authenticated"); // Refused before the challenge: nothing was posted to the callback. expect(receiver.received).toHaveLength(0); - const off = await rpc( - "events/unsubscribe", - { - name: "comment.created", - arguments: { documentId: "d" }, - delivery: { mode: "webhook", url: anonymous.url }, - }, - null, - ); + const off = await unsubscribe({ documentId: "d" }, anonymous.url, null); expect(off.error?.data?.reason).toBe("not_authenticated"); }); + it("refuses before validating anything, so an anonymous caller learns nothing", async () => { + const { subscribe } = setup(); + const unknown = await subscribe({ documentId: "d" }, { user: null, name: "nope" }); + expect(unknown.error?.data?.reason).toBe("not_authenticated"); + const badUrl = await subscribe({}, { user: null, url: "https://10.0.0.1/x" }); + expect(badUrl.error?.data?.reason).toBe("not_authenticated"); + }); + + it("refuses when principal throws, rejects, or returns no usable id", async () => { + const resolvers = [ + () => { + throw new Error("no session"); + }, + async () => { + throw new Error("session store is down"); + }, + () => "", + () => undefined, + () => ({ id: "alice" }), + () => 42, + ]; + for (const resolver of resolvers) { + const { subscribe, receiver, store } = setup({ principal: resolver as never }); + const result = await subscribe({ documentId: "d" }); + expect(result.error?.data?.reason).toBe("not_authenticated"); + expect(receiver.received).toHaveLength(0); + expect(await store.find("comment.created")).toHaveLength(0); + } + }); + it("has no default secret key: a missing one fails the first subscribe", async () => { const saved = process.env.MCP_EVENTS_SECRET_KEY; delete process.env.MCP_EVENTS_SECRET_KEY; @@ -587,76 +653,61 @@ describe("principal", () => { createEventLayer({ store: new MemorySubscriptionStore(), delivery: new InlineDelivery(), - secretKey: "k", + secretKey: TEST_SECRET_KEY, } as unknown as EventLayerOptions), ).toThrow(/principal/); }); }); -describe("task.finished", () => { - it("fires once when a task settles, only to the owner's subscriptions", async () => { - const { events, subscribe, receiver } = setup(); - const taskFinished = taskFinishedEvent(events); - const tasks = createTaskLayer({ - store: new MemoryTaskStore(), - dispatcher: new InlineTaskDispatcher(), - principal: userIdOf, - onSettle: taskFinished.onSettle, - }); - tasks.define("slow", { description: "slow", inputSchema: z.object({}) }, async () => ({ - content: [{ type: "text", text: "done" }], - })); - const handler = createMcpHandler(() => { - const server = new McpServer({ name: "t", version: "1.0.0" }); - tasks.register(server); - return server; - }); +describe("the secret key", () => { + it("must be base64 of at least 32 random bytes", () => { + for (const weak of ["test-key", "hunter2", Buffer.alloc(16).toString("base64"), ""]) { + expect(() => new SecretBox(weak), weak).toThrow(/32 random bytes/); + } + expect(() => new SecretBox(TEST_SECRET_KEY)).not.toThrow(); + }); - const alice = await subscribe({}, { user: "alice", name: "task.finished" }); - await subscribe({}, { user: "bob", name: "task.finished" }); - expect(alice.error).toBeUndefined(); + it("refuses a weak key at the first subscribe", async () => { + const { subscribe } = setup({ secretKey: "password" }); + expect((await subscribe({ documentId: "d" })).error?.message).toMatch(/32 random bytes/); + }); - const response = await handler.fetch( - request("tools/call", { name: "slow", arguments: {} }, 1), - as("alice"), - ); - const taskId = ( - JSON.parse(await response.text()) as { result: { structuredContent: { taskId: string } } } - ).result.structuredContent.taskId; - await new Promise((resolve) => setTimeout(resolve, 50)); + it("opens nothing that was tampered with or sealed under another key", async () => { + const box = new SecretBox(TEST_SECRET_KEY); + const sealed = await box.seal("whsec_abc"); + expect(await box.open(sealed)).toBe("whsec_abc"); + const [version, iv, payload] = sealed.split("."); + const flipped = Buffer.from(payload!, "base64"); + flipped[0]! ^= 1; + expect(await box.open(`${version}.${iv}.${flipped.toString("base64")}`)).toBeNull(); + expect(await new SecretBox(Buffer.alloc(32, 9).toString("base64")).open(sealed)).toBeNull(); + expect(await box.open("garbage")).toBeNull(); + }); - const deliveries = receiver.deliveries(); - expect(deliveries).toHaveLength(1); - expect(deliveries[0]?.url).toBe(alice.url); - expect(deliveries[0]?.body).toMatchObject({ - name: "task.finished", - eventId: `evt_task_${taskId}`, - data: { taskId, status: "completed", result: { content: [{ type: "text", text: "done" }] } }, - }); + it("drops deliveries for secrets sealed under a rotated key, until the host refreshes", async () => { + const { subscribe, store, receiver, done, commentCreated } = setup(); + const sub = await subscribe({ documentId: "d" }); + const stored = (await store.get(String(sub.result?.id)))!; + const otherKey = new SecretBox(Buffer.alloc(32, 9).toString("base64")); + await store.put({ ...stored, encryptedSecret: await otherKey.seal(sub.secret) }); - // A cancel after completion settles nothing, so nothing fires. - await tasks.cancelTask(taskId); - expect(receiver.deliveries()).toHaveLength(1); + await commentCreated.emit({ documentId: "d", author: "a", text: "x" }); + expect(receiver.deliveries()).toHaveLength(0); + expect(done()).toEqual([true]); }); }); -describe("helpers", () => { - it("enumerates every subset of the emitted arguments", () => { - expect(matchKeys({ a: 1, b: 2 }).sort()).toEqual( - ['{"a":1,"b":2}', '{"a":1}', '{"b":2}', "{}"].sort(), - ); - const tooMany = Object.fromEntries([...Array(9).keys()].map((i) => [`k${i}`, i])); - expect(() => matchKeys(tooMany)).toThrow(); - }); - - it("refuses callback URLs that point at the server's own network", () => { +describe("callback URLs", () => { + it("refuses every IP literal and every name on the server's own network", () => { for (const url of [ "http://example.com", "https://localhost/x", "https://10.0.0.1/x", "https://192.168.1.1/x", + "https://8.8.8.8/cb", "https://[::1]/x", "https://[::ffff:127.0.0.1]/x", + "https://[2606:4700:4700::1111]/cb", "https://metadata.google.internal/x", "https://user:pw@example.com/x", "https://intranet/x", @@ -664,28 +715,15 @@ describe("helpers", () => { "https://localhost./x", "https://foo.internal./x", "https://x.local./x", - // IPv4 written another way, or embedded in IPv6. + // IPv4 written another way: the URL parser normalizes it to a literal, which is refused. "https://0x7f000001/x", - "https://[::127.0.0.1]/x", - "https://[64:ff9b::127.0.0.1]/x", - "https://[64:ff9b:1::1]/x", - "https://[2002:7f00:1::]/x", - "https://[fe80::1]/x", - "https://[fd00::1]/x", - // Reserved and documentation ranges. - "https://198.18.0.1/x", - "https://192.0.0.1/x", - "https://192.0.2.1/x", - "https://198.51.100.1/x", - "https://203.0.113.1/x", + "https://2130706433/x", + "https://127.1/x", ]) { expect(callbackUrlProblem(url), url).not.toBeNull(); } expect(callbackUrlProblem("https://receiver.example.com/cb")).toBeNull(); - expect(callbackUrlProblem("https://8.8.8.8/cb")).toBeNull(); expect(callbackUrlProblem("https://receiver.example.com./cb")).toBeNull(); - expect(callbackUrlProblem("https://[2606:4700:4700::1111]/cb")).toBeNull(); - expect(callbackUrlProblem("https://[64:ff9b::8.8.8.8]/cb")).toBeNull(); expect(callbackUrlProblem("http://localhost:3000/cb", true)).toBeNull(); }); }); diff --git a/packages/mcp-toolkit/src/events/core.ts b/packages/mcp-toolkit/src/events/core.ts index 661263b..7e793b0 100644 --- a/packages/mcp-toolkit/src/events/core.ts +++ b/packages/mcp-toolkit/src/events/core.ts @@ -3,70 +3,48 @@ * `events/*` methods a host calls, and an `emit` that delivers signed Standard Webhooks POSTs to * the matching subscriptions `authorize` allows. Webhook delivery only, as ChatGPT ships it. */ -import { ProtocolError, type McpServer } from "@modelcontextprotocol/server"; +import { ProtocolError, type AuthInfo, type McpServer } from "@modelcontextprotocol/server"; import * as z from "zod"; import { callerOf, requirePrincipal, resolvePrincipal, - type Caller, - type CallerAuth, - type Principal, type PrincipalResolver, } from "../shared/auth.js"; +import { env } from "../shared/env.js"; import { randomBase64Url, randomHex, sha256Hex } from "../shared/crypto.js"; -import type { - DeliveryJob, - EventDelivery, - EventEnvelope, - SendOutcome, - SubscriptionStore, -} from "./types.js"; +import type { DeliveryJob, EventDelivery, EventEnvelope, SubscriptionStore } from "./types.js"; import { SecretBox, callbackUrlProblem, decodeSecret, postSigned, readCapped } from "./webhooks.js"; /** The draft's cap on a delivered envelope. */ -export const MAX_PAYLOAD_BYTES = 256 * 1024; +const MAX_PAYLOAD_BYTES = 256 * 1024; /** `CallbackEndpointError`: the callback failed verification. */ -export const CALLBACK_ENDPOINT_ERROR = -32015; +const CALLBACK_ENDPOINT_ERROR = -32015; const INVALID_PARAMS = -32602; const DAY_MS = 24 * 60 * 60 * 1000; +/** Lifetime granted when the host does not ask for one. */ const DEFAULT_TTL_MS = 7 * DAY_MS; -const DEFAULT_MAX_TTL_MS = 30 * DAY_MS; -const DEFAULT_TIMEOUT_MS = 10_000; -/** Matching enumerates subsets of the emitted arguments, so their count is capped. */ -const MAX_MATCH_KEYS = 8; +/** The most a subscription is granted. */ +const MAX_TTL_MS = 30 * DAY_MS; /** The challenge echo is tiny; never read more than this from a callback. */ const MAX_CHALLENGE_RESPONSE_BYTES = 4096; -export type { Caller, CallerAuth, Principal }; - export type EventLayerOptions = { store: SubscriptionStore; delivery: EventDelivery; + /** + * Who is calling, usually your user id from `auth`. Required, and it must return an id: throw + * when it can't, and the subscribe is refused as not authenticated. + */ + principal: PrincipalResolver; /** * Encrypts the hosts' signing secrets at rest. Defaults to `MCP_EVENTS_SECRET_KEY`; one of the * two is required. Generate one with `openssl rand -base64 32`. */ secretKey?: string; - /** - * Who is calling, usually your user id from `auth`. Required, and it must return an id: throw - * when it can't, and the subscribe is refused as not authenticated. Return `{ id, context }` to store non-secret context (an - * org id) that `authorize` gets back before each delivery. Never put the token in it. - */ - principal: PrincipalResolver; - defaults?: { - /** Lifetime granted when the host does not ask for one. Defaults to 7 days. */ - ttlMs?: number; - /** The most the server grants. Defaults to 30 days. */ - maxTtlMs?: number; - }; /** Accept `http://` and private callback URLs. Local development only: it disables the SSRF checks. */ allowInsecureCallbacks?: boolean; - /** Timeout for each POST to a callback. Defaults to 10s. */ - timeoutMs?: number; - /** Override `fetch`, e.g. in tests. */ - fetch?: typeof fetch; }; /** Subscription arguments are an object schema, so they can be matched field by field. */ @@ -76,112 +54,46 @@ export type EventInputSchema = z.ZodObject; export type AuthorizeCaller = { /** The subscriber's id, from `principal`. */ principal: string; - /** The context `principal` returned at subscribe time, or `{}`. */ - context: Record; /** `"subscribe"` on every subscribe and refresh, `"deliver"` before every delivery. */ phase: "subscribe" | "deliver"; /** Only at subscribe time: no request stands behind a delivery, and the token is never stored. */ - auth?: CallerAuth; + auth?: AuthInfo; /** Only at subscribe time. */ request?: Request; }; -export type EventConfig< - Input extends EventInputSchema, - Payload extends z.ZodType, - Personal extends boolean = false, -> = { +export type EventConfig = { title?: string; /** What the event means, for the model and the user. */ description: string; - /** What a subscriber filters on, e.g. `z.object({ repo: z.string() })`. Defaults to none. */ + /** + * What a subscriber filters on, e.g. `z.object({ repo: z.string() })`. Every field must also be + * a payload field: an emit is routed by the payload's values. Defaults to none. + */ input?: Input; /** The shape of `data` in every delivery. */ payload: Payload; /** - * Whether this subscriber may subscribe with these arguments and receive what they match, e.g. - * whether they can read that document. Required. Runs on every subscribe and refresh, and again - * before every delivery, so revoked access stops the events. Pass `() => true` for an event any - * authenticated caller may hear. + * Whether this subscriber may see events with these values, e.g. whether they can read that + * repo. Required. On subscribe it gets the subscription's arguments; before every delivery it + * gets the event's own values for the input fields, so a subscriber without a filter is still + * checked against what each event is about, and revoked access stops the events. Pass + * `() => true` for an event any authenticated caller may hear. */ - authorize: (args: z.output, caller: AuthorizeCaller) => boolean | Promise; - /** - * A personal event belongs to specific users ("your export finished"), so every `emit` must say - * who with `to`. The type makes `to` required, and so does a runtime check. - */ - personal?: Personal; - /** An extra filter for what exact argument matching cannot express. Runs per subscription. */ - match?: (args: z.output, payload: z.output) => boolean; -}; - -/** One user id, or several. */ -export type Recipients = string | readonly string[]; - -/** - * `args` is optional when every input field is also a payload field (the match values are read - * from the payload), and required otherwise, so a filter can never silently match nothing. - */ -type ArgsOption = string extends keyof Input - ? { args?: Partial } // no input schema: nothing to match on - : [Exclude] extends [never] - ? { args?: Partial } - : { args: Input }; - -type ToOption = Personal extends true - ? { to: Recipients } - : { to?: Recipients }; - -export type EmitOptions = ArgsOption< - Input, - Payload -> & - ToOption & { - /** Stable across retries and sent as `webhook-id`. Reuse an id to deduplicate an emit. */ - eventId?: string; - /** Defaults to now. */ - timestamp?: Date; - }; - -/** The options argument may be left out when none of its fields are required. */ -type OptionsArg = Partial extends T ? [options?: T] : [options: T]; - -type AnyEmitOptions = { - args?: Record; - to?: Recipients; - eventId?: string; - timestamp?: Date; + authorize: ( + args: Partial>, + caller: AuthorizeCaller, + ) => boolean | Promise; }; -export type EmitResult = { - eventId: string; - /** How many subscriptions the event was handed to. */ - matched: number; -}; - -export type EventHandle< - Input extends EventInputSchema, - Payload extends z.ZodType, - Personal extends boolean = false, -> = { +export type EventHandle = { readonly name: string; /** - * Validates the payload and hands it to every matching subscription, or only those of the users - * in `to`. `authorize` is checked again before each delivery. + * Validates the payload and hands it to every subscription whose arguments match the payload's + * values. `authorize` is checked again before each delivery. Reuse an `eventId` (sent as + * `webhook-id`) to deduplicate an emit. */ - emit( - payload: z.input, - ...options: OptionsArg, z.output, Personal>> - ): Promise; -}; - -/** What `events/list` returns for each event. */ -export type EventDescriptor = { - name: string; - title?: string; - description: string; - delivery: ["webhook"]; - inputSchema: Record; - payloadSchema: Record; + emit(payload: z.input, options?: { eventId?: string }): Promise<{ eventId: string }>; }; export type EventLayer = { @@ -189,33 +101,21 @@ export type EventLayer = { define< Input extends EventInputSchema = z.ZodObject>, Payload extends z.ZodType = z.ZodType, - Personal extends boolean = false, >( name: string, - config: EventConfig, - ): EventHandle; + config: EventConfig, + ): EventHandle; /** Declares the `events` capability and serves `events/list`, `/subscribe` and `/unsubscribe`. */ register(server: McpServer): void; /** The transport's delivery endpoint: `export const POST = events.createDeliveryHandler()`. */ createDeliveryHandler(): (request: Request) => Promise; - /** The descriptors `events/list` returns. */ - list(): EventDescriptor[]; }; -/** Thrown by `emit` when the envelope would exceed the draft's 256 KiB cap. */ -export class EventPayloadTooLargeError extends Error { - override readonly name = "EventPayloadTooLargeError"; - constructor(readonly bytes: number) { - super(`Event envelope is ${bytes} bytes; the limit is ${MAX_PAYLOAD_BYTES}.`); - } -} - type Definition = { - config: EventConfig; + config: EventConfig; input: EventInputSchema; - /** Input fields a subscriber must give, so an emit must have a value for them. */ - required: string[]; - descriptor: EventDescriptor; + /** What `events/list` returns for this event. */ + descriptor: Record; }; const subscribeParams = z.looseObject({ @@ -241,12 +141,14 @@ const listParams = z.looseObject({ cursor: z.string().nullish() }); export function createEventLayer(options: EventLayerOptions): EventLayer { const { store, delivery } = options; const principal = requirePrincipal(options.principal, "createEventLayer"); + const definitions = new Map(); + // Resolved on first use, because layers are built at module scope during framework builds. // There is no default key: a missing one fails the first subscribe or delivery. let box: SecretBox | undefined; const secretBox = (): SecretBox => { if (box) return box; - const secretKey = options.secretKey ?? getEnv("MCP_EVENTS_SECRET_KEY"); + const secretKey = options.secretKey ?? env("MCP_EVENTS_SECRET_KEY"); if (!secretKey) { throw new Error( "createEventLayer needs a secretKey (or MCP_EVENTS_SECRET_KEY) to encrypt webhook secrets at rest. Generate one with: openssl rand -base64 32", @@ -254,30 +156,11 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { } return (box = new SecretBox(secretKey)); }; - /** A secret sealed under a rotated key no longer opens, which forces a re-verification. */ - const openOrEmpty = async (sealed: string): Promise => { - const opener = secretBox(); // a missing key throws here, outside the catch - try { - return await opener.open(sealed); - } catch { - return ""; - } - }; - const ttl = { - initial: options.defaults?.ttlMs ?? DEFAULT_TTL_MS, - max: options.defaults?.maxTtlMs ?? DEFAULT_MAX_TTL_MS, - }; - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const definitions = new Map(); - function define< - Input extends EventInputSchema, - Payload extends z.ZodType, - Personal extends boolean = false, - >( + function define( name: string, - config: EventConfig, - ): EventHandle { + config: EventConfig, + ): EventHandle { if (definitions.has(name)) throw new Error(`Event "${name}" is already defined`); if (typeof config.authorize !== "function") { throw new Error( @@ -285,12 +168,16 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { ); } const input = (config.input ?? z.object({})) as EventInputSchema; + const payloadFields = config.payload instanceof z.ZodObject ? config.payload.shape : {}; + const missing = Object.keys(input.shape).filter((key) => !(key in payloadFields)); + if (missing.length > 0) { + throw new Error( + `Event "${name}": input field(s) ${missing.join(", ")} must also be payload fields, because an emit is routed by the payload's values.`, + ); + } definitions.set(name, { - config: config as unknown as EventConfig, + config: config as unknown as EventConfig, input, - required: Object.entries(input.shape) - .filter(([, field]) => !(field as z.ZodType).safeParse(undefined).success) - .map(([key]) => key), descriptor: { name, ...(config.title ? { title: config.title } : {}), @@ -300,78 +187,54 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { payloadSchema: jsonSchema(config.payload, "output"), }, }); - return { - name, - emit: (payload, ...rest) => emit(name, payload, rest[0] as AnyEmitOptions | undefined), - }; + return { name, emit: (payload, emitOptions) => emit(name, payload, emitOptions?.eventId) }; } async function emit( name: string, rawPayload: unknown, - emitOptions: AnyEmitOptions = {}, - ): Promise { + eventId = `evt_${randomHex(12)}`, + ): Promise<{ eventId: string }> { const definition = definitions.get(name); if (!definition) throw new Error(`Unknown event "${name}"`); - const to = emitOptions.to; - const recipients = to === undefined ? undefined : new Set(typeof to === "string" ? [to] : to); - if (definition.config.personal && !recipients) { - throw new Error(`"${name}" is a personal event: emit it with \`to\`.`); - } const payload = definition.config.payload.parse(rawPayload); - const args = (emitOptions.args ?? pickArgs(definition.input, payload)) as Record< - string, - unknown - >; - for (const key of definition.required) { - // Every subscriber filtered on it, so without a value the event could match nobody. - if (args[key] === undefined) { - throw new Error( - `emit("${name}") has no value for "${key}": put it in the payload or in \`args\`.`, - ); - } - } const envelope: EventEnvelope = { - eventId: emitOptions.eventId ?? `evt_${randomHex(12)}`, + eventId, name, - timestamp: (emitOptions.timestamp ?? new Date()).toISOString(), + timestamp: new Date().toISOString(), data: payload, cursor: null, }; const bytes = new TextEncoder().encode(JSON.stringify(envelope)).length; - if (bytes > MAX_PAYLOAD_BYTES) throw new EventPayloadTooLargeError(bytes); - if (recipients?.size === 0) return { eventId: envelope.eventId, matched: 0 }; - - const match = definition.config.match; - const targets = (await store.find(name, matchKeys(args))).filter( - (sub) => - (!recipients || recipients.has(sub.subscriber)) && - (!match || match(sub.args as never, payload as never)), - ); + if (bytes > MAX_PAYLOAD_BYTES) { + throw new Error(`Event envelope is ${bytes} bytes; the limit is ${MAX_PAYLOAD_BYTES}.`); + } + const values = valuesOf(definition.input, payload); + const targets = (await store.find(name)).filter((sub) => matches(sub.args, values)); if (targets.length > 0) { await delivery.enqueue(targets.map((sub) => ({ subscriptionId: sub.id, envelope }))); } - return { eventId: envelope.eventId, matched: targets.length }; + return { eventId }; } - async function send(job: DeliveryJob): Promise { + async function send(job: DeliveryJob): Promise { const sub = await store.get(job.subscriptionId); const definition = sub && definitions.get(sub.event); - if (!sub || !definition) return "dropped"; - // Access can be revoked after subscribing, so ask again before every delivery. + if (!sub || !definition) return true; + // Access can be revoked after subscribing, so ask again before every delivery, about this + // event's own values rather than the subscription's (possibly empty) filter. let allowed: boolean; try { - allowed = await definition.config.authorize(sub.args as never, { - principal: sub.subscriber, - context: sub.context ?? {}, - phase: "deliver", - }); + allowed = await definition.config.authorize( + valuesOf(definition.input, job.envelope.data) as never, + { principal: sub.subscriber, phase: "deliver" }, + ); } catch { - return "retry"; + return false; } - if (!allowed) return "dropped"; - const secret = await openOrEmpty(sub.encryptedSecret); - if (!secret) return "dropped"; // sealed under a rotated key: wait for the host's refresh + if (!allowed) return true; + const secret = await secretBox().open(sub.encryptedSecret); + if (!secret) return true; // sealed under a rotated key: wait for the host's refresh let response: Response; try { response = await postSigned({ @@ -380,27 +243,27 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { webhookId: job.envelope.eventId, body: JSON.stringify(job.envelope), subscriptionId: sub.id, - timeoutMs, - fetch: options.fetch, }); } catch { - return "retry"; + return false; } // Release the connection: the body is never needed. await response.body?.cancel().catch(() => undefined); - if (response.ok) return "delivered"; - if (response.status === 410) { - await store.delete(sub); - return "gone"; - } - // Redirects are never followed, so retrying one would fail the same way. - if (response.status === 413 || (response.status >= 300 && response.status < 400)) { - return "dropped"; - } - return "retry"; + if (response.status === 410) await store.delete(sub); // the host dropped the subscription + // A 413 or a redirect (never followed) would fail the same way again; anything else retries. + return ( + response.ok || + response.status === 410 || + response.status === 413 || + (response.status >= 300 && response.status < 400) + ); } async function subscribe(params: z.output, ctx: unknown) { + const caller = callerOf(ctx); + const subscriber = await resolvePrincipal(principal, caller); + if (!subscriber) throw notAuthenticated(); + const definition = definitions.get(params.name); if (!definition) throw invalid(`Unknown event "${params.name}"`, "unknown_event"); if (params.delivery.mode !== "webhook") { @@ -415,7 +278,7 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { } const args = parsed.data as Record; const secret = params.delivery.secret; - if (!secret || !decodeSecret(secret)) { + if (!decodeSecret(secret)) { throw invalid( "delivery.secret must be whsec_ followed by 24-64 base64 bytes", "invalid_secret", @@ -423,47 +286,38 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { } const url = params.delivery.url; const problem = callbackUrlProblem(url, options.allowInsecureCallbacks); - if (problem || !url) + if (problem || !url || !secret) { throw invalid(problem ?? "callback URL is missing", "invalid_callback_url"); + } - const caller = callerOf(ctx); - const who = await resolvePrincipal(principal, caller); - if (!who) throw notAuthenticated(); - const owner = who.id; - if ( - definition.config.authorize && - !(await definition.config.authorize(args, { - principal: owner, - context: who.context, - phase: "subscribe", - auth: caller.auth, - request: caller.request, - })) - ) { + const allowed = await definition.config.authorize(args, { + principal: subscriber, + phase: "subscribe", + auth: caller.auth, + request: caller.request, + }); + if (!allowed) { throw invalid("Not authorized to subscribe with these arguments", "not_authorized"); } - const id = await subscriptionId(owner, url, params.name, args); + const id = await subscriptionId(subscriber, url, params.name, args); const existing = await store.get(id); // A refresh with the same secret skips the challenge; a new secret proves the callback again. - const verified = existing !== null && (await openOrEmpty(existing.encryptedSecret)) === secret; + const verified = + existing !== null && (await secretBox().open(existing.encryptedSecret)) === secret; if (!verified) await verifyCallback(url, secret, id); - const requested = - params.ttlMs === undefined ? ttl.initial : params.ttlMs === null ? ttl.max : params.ttlMs; - const granted = Math.min(requested, ttl.max); + const requested = params.ttlMs === undefined ? DEFAULT_TTL_MS : (params.ttlMs ?? MAX_TTL_MS); + const granted = Math.min(requested, MAX_TTL_MS); const now = Date.now(); const expiresAt = now + granted; await store.put({ id, event: params.name, args, - argsKey: canonicalJson(args), url, - encryptedSecret: - verified && existing ? existing.encryptedSecret : await secretBox().seal(secret), - subscriber: owner, - ...(Object.keys(who.context).length > 0 ? { context: who.context } : {}), + encryptedSecret: verified ? existing.encryptedSecret : await secretBox().seal(secret), + subscriber, createdAt: existing?.createdAt ?? new Date(now).toISOString(), expiresAt, }); @@ -476,16 +330,14 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { } async function unsubscribe(params: z.output, ctx: unknown) { - const owner = (await resolvePrincipal(principal, callerOf(ctx)))?.id; - if (!owner) throw notAuthenticated(); + const subscriber = await resolvePrincipal(principal, callerOf(ctx)); + if (!subscriber) throw notAuthenticated(); const parsed = definitions.get(params.name)?.input.safeParse(params.arguments ?? {}); if (parsed?.success && params.delivery.url) { + // The id includes the caller, so a caller can only ever remove their own subscription. const args = parsed.data as Record; - await store.delete({ - id: await subscriptionId(owner, params.delivery.url, params.name, args), - event: params.name, - argsKey: canonicalJson(args), - }); + const id = await subscriptionId(subscriber, params.delivery.url, params.name, args); + await store.delete({ id, event: params.name }); } return {}; } @@ -505,12 +357,10 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { webhookId: `msg_verification_${randomHex(12)}`, body: JSON.stringify({ type: "verification", challenge }), subscriptionId: id, - timeoutMs, - fetch: options.fetch, }); const text = await readCapped(response, MAX_CHALLENGE_RESPONSE_BYTES); if (!response.ok) detail = `HTTP ${response.status}`; - else if (parseChallenge(text) !== challenge) detail = "challenge not echoed"; + else if (echoedChallenge(text) !== challenge) detail = "challenge not echoed"; } catch (error) { detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error); } @@ -524,29 +374,14 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { const low = server.server; low.registerCapabilities({ events: {} } as never); low.setRequestHandler("events/list", { params: listParams }, async () => ({ - events: list(), + events: [...definitions.values()].map((d) => d.descriptor), nextCursor: null, })); - low.setRequestHandler("events/subscribe", { params: subscribeParams }, (params, ctx) => - subscribe(params, ctx), - ); - low.setRequestHandler("events/unsubscribe", { params: unsubscribeParams }, (params, ctx) => - unsubscribe(params, ctx), - ); - } - - function createDeliveryHandler(): (request: Request) => Promise { - if (!delivery.createDeliveryHandler) { - throw new Error("This delivery sends in-process and has no endpoint. Use QStashDelivery."); - } - return delivery.createDeliveryHandler(); + low.setRequestHandler("events/subscribe", { params: subscribeParams }, subscribe); + low.setRequestHandler("events/unsubscribe", { params: unsubscribeParams }, unsubscribe); } - const list = () => [...definitions.values()].map((d) => d.descriptor); - - delivery.attach?.({ send }); - - return { define, register, createDeliveryHandler, list }; + return { define, register, createDeliveryHandler: () => delivery.createDeliveryHandler(send) }; } /** JSON with object keys sorted at every level, so equal values serialize identically. */ @@ -568,21 +403,6 @@ function sortKeys(value: unknown): unknown { return value; } -/** Every `argsKey` an emit with these arguments matches: one per subset, including `{}`. */ -export function matchKeys(args: Record): string[] { - const entries = Object.entries(args).filter(([, value]) => value !== undefined); - if (entries.length > MAX_MATCH_KEYS) { - throw new Error( - `An emit can match on at most ${MAX_MATCH_KEYS} arguments, got ${entries.length}`, - ); - } - const keys: string[] = []; - for (let mask = 0; mask < 1 << entries.length; mask++) { - keys.push(canonicalJson(Object.fromEntries(entries.filter((_, i) => mask & (1 << i))))); - } - return keys; -} - /** Deterministic, as the draft asks: same subscriber, callback, event and arguments give the same id. */ export async function subscriptionId( subscriber: string, @@ -593,17 +413,24 @@ export async function subscriptionId( return `sub_${(await sha256Hex(canonicalJson([subscriber, url, event, args]))).slice(0, 32)}`; } -function pickArgs(input: EventInputSchema, payload: unknown): Record { - if (!payload || typeof payload !== "object") return {}; - const picked: Record = {}; - for (const key of Object.keys(input.shape)) { - const value = (payload as Record)[key]; - if (value !== undefined) picked[key] = value; - } - return picked; +/** The payload's values for the event's input fields: what an emit is routed and authorized by. */ +function valuesOf(input: EventInputSchema, payload: unknown): Record { + const record = payload && typeof payload === "object" ? (payload as Record) : {}; + return Object.fromEntries( + Object.keys(input.shape) + .filter((key) => record[key] !== undefined) + .map((key) => [key, record[key]]), + ); } -function parseChallenge(text: string): unknown { +/** A subscription matches when the event has every value it filtered on. */ +function matches(filter: Record, values: Record): boolean { + return Object.entries(filter).every( + ([key, value]) => key in values && canonicalJson(values[key]) === canonicalJson(value), + ); +} + +function echoedChallenge(text: string): unknown { try { return (JSON.parse(text) as { challenge?: unknown } | null)?.challenge; } catch { @@ -626,7 +453,3 @@ function notAuthenticated(): ProtocolError { "not_authenticated", ); } - -function getEnv(name: string): string | undefined { - return typeof process === "object" ? process.env?.[name] : undefined; -} diff --git a/packages/mcp-toolkit/src/events/index.ts b/packages/mcp-toolkit/src/events/index.ts index 64a0c14..57c33ec 100644 --- a/packages/mcp-toolkit/src/events/index.ts +++ b/packages/mcp-toolkit/src/events/index.ts @@ -1,53 +1,26 @@ /** * `@upstash/mcp-toolkit/events`: MCP Events (webhook delivery) for servers on the official - * TypeScript SDK, with in-memory backends for tests. The Upstash backends are in - * `@upstash/mcp-toolkit/upstash`. + * TypeScript SDK. The Upstash backends are in `@upstash/mcp-toolkit/upstash`. */ export { createEventLayer, - canonicalJson, - matchKeys, - subscriptionId, - EventPayloadTooLargeError, - CALLBACK_ENDPOINT_ERROR, - MAX_PAYLOAD_BYTES, - type Caller, - type Principal, - type CallerAuth, - type EmitOptions, type AuthorizeCaller, - type Recipients, - type EmitResult, type EventConfig, - type EventDescriptor, type EventHandle, type EventInputSchema, type EventLayer, type EventLayerOptions, } from "./core.js"; -export type { PrincipalResolver } from "../shared/auth.js"; +export type { Caller, PrincipalResolver } from "../shared/auth.js"; export type { - DeliveryEndpoints, DeliveryJob, EventDelivery, EventEnvelope, - SendOutcome, + SendJob, Subscription, - SubscriptionRef, SubscriptionStore, } from "./types.js"; -export { - signWebhook, - verifyWebhook, - decodeSecret, - callbackUrlProblem, - SecretBox, -} from "./webhooks.js"; -export { taskFinishedEvent, type TaskFinishedPayload } from "./tasks.js"; -export { InlineDelivery, MemorySubscriptionStore } from "./backends/memory.js"; - -export { SDK_TELEMETRY } from "../telemetry.js"; -export { VERSION } from "../version.js"; +export { verifyWebhook } from "./webhooks.js"; diff --git a/packages/mcp-toolkit/src/events/tasks.ts b/packages/mcp-toolkit/src/events/tasks.ts deleted file mode 100644 index f368ea4..0000000 --- a/packages/mcp-toolkit/src/events/tasks.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * A `task.finished` event, fired once when a task settles, to its owner only: - * - * ```ts - * const taskFinished = taskFinishedEvent(events); - * const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); - * ``` - * - * A host subscribes with no arguments for every task its user starts, or with a `taskId`. - */ -import * as z from "zod"; -import type { Task } from "../tasks/types.js"; -import { EventPayloadTooLargeError, type EventLayer } from "./core.js"; - -const taskFinishedInput = z.object({ - taskId: z - .string() - .optional() - .describe("Only this task. Omit to hear about every task you start."), -}); - -const taskFinishedPayload = z.object({ - taskId: z.string(), - status: z.enum(["completed", "failed", "cancelled"]), - statusMessage: z.string().optional(), - /** The tool result, when it fits in the 256 KiB envelope. Otherwise call `task_status`. */ - result: z.record(z.string(), z.unknown()).optional(), - error: z - .object({ code: z.number(), message: z.string(), data: z.unknown().optional() }) - .optional(), -}); - -export type TaskFinishedPayload = z.output; - -export function taskFinishedEvent( - events: EventLayer, - options: { name?: string; description?: string } = {}, -) { - const event = events.define(options.name ?? "task.finished", { - description: - options.description ?? - "A long-running task you started finished: completed, failed or was cancelled. Carries the result when it is small enough.", - input: taskFinishedInput, - payload: taskFinishedPayload, - // Personal: each emit goes `to` the task's owner, so a subscription with no arguments only - // hears about its own tasks. Nothing else to check. - personal: true, - authorize: () => true, - }); - - async function onSettle(task: Task): Promise { - if (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") { - return; - } - const payload: TaskFinishedPayload = { - taskId: task.taskId, - status: task.status, - ...(task.statusMessage ? { statusMessage: task.statusMessage } : {}), - ...(task.result ? { result: task.result } : {}), - ...(task.error ? { error: task.error } : {}), - }; - const emitOptions = { - to: task.owner, - args: { taskId: task.taskId }, - // Stable per task, so a repeated settle hook cannot deliver twice. - eventId: `evt_task_${task.taskId}`, - }; - try { - await event.emit(payload, emitOptions); - } catch (error) { - if (!(error instanceof EventPayloadTooLargeError)) throw error; - // Too big to inline: send the outcome, and let the host fetch the result with task_status. - const { result: _dropped, ...withoutResult } = payload; - await event.emit(withoutResult, emitOptions); - } - } - - return { event, onSettle }; -} diff --git a/packages/mcp-toolkit/src/events/types.ts b/packages/mcp-toolkit/src/events/types.ts index 4180167..f7b9c6d 100644 --- a/packages/mcp-toolkit/src/events/types.ts +++ b/packages/mcp-toolkit/src/events/types.ts @@ -21,40 +21,30 @@ export type Subscription = { /** Derived from the subscriber, callback URL, event name and arguments. */ id: string; event: string; - /** The validated subscription arguments. */ + /** The validated subscription arguments: an event matches when its payload has these values. */ args: Record; - /** Canonical JSON of {@link args}, which emits are matched on. */ - argsKey: string; /** The callback URL the host gave. */ url: string; /** The `whsec_` signing secret, encrypted with the layer's `secretKey`. */ encryptedSecret: string; /** Who subscribed: the id `principal` returned. */ subscriber: string; - /** Non-secret context `principal` returned, handed back to `authorize` before each delivery. */ - context?: Record; /** ISO-8601. */ createdAt: string; /** Epoch milliseconds. */ expiresAt: number; }; -/** The fields that locate a subscription in a store's index. */ -export type SubscriptionRef = Pick; - -/** - * Durable storage for subscriptions, indexed by `(event, argsKey)`. The layer computes every key - * an emit can match, so a store needs no matching logic of its own. - */ +/** Durable storage for subscriptions. The layer does the matching, so a store only indexes by event. */ export interface SubscriptionStore { /** Creates or replaces a subscription. Must have committed before it resolves. */ put(subscription: Subscription): Promise; /** A live subscription, or `null`. */ get(id: string): Promise; - /** Removes a subscription and its index entry. A no-op when it does not exist. */ - delete(subscription: SubscriptionRef): Promise; - /** Every live subscription to `event` whose `argsKey` is one of `argsKeys`. */ - find(event: string, argsKeys: readonly string[]): Promise; + /** Removes a subscription. A no-op when it does not exist. */ + delete(subscription: Pick): Promise; + /** Every live subscription to `event`. */ + find(event: string): Promise; } /** One envelope for one subscription. */ @@ -63,28 +53,15 @@ export type DeliveryJob = { envelope: EventEnvelope; }; -/** One POST to a callback, as the layer classifies it. */ -export type SendOutcome = - /** 2xx. */ - | "delivered" - /** 410: the host dropped the subscription, and the layer deleted it. */ - | "gone" - /** 413, a redirect, a missing subscription, or `authorize` now refuses it: retrying cannot help. */ - | "dropped" - /** Anything else: retry. */ - | "retry"; - -/** The layer's entry point, handed to a transport by {@link EventDelivery.attach}. */ -export type DeliveryEndpoints = { - /** Re-checks `authorize`, signs the envelope fresh, POSTs it and classifies the answer. */ - send(job: DeliveryJob): Promise; -}; +/** + * Re-checks `authorize`, signs the envelope fresh and POSTs it. Resolves `true` when the job is + * finished (delivered, or retrying cannot help) and `false` when the transport should retry. + */ +export type SendJob = (job: DeliveryJob) => Promise; export interface EventDelivery { - /** Accepts the jobs durably (or sends them) before resolving. */ + /** Accepts the jobs durably before resolving. */ enqueue(jobs: DeliveryJob[]): Promise; - /** Receives the layer's entry point when passed to `createEventLayer`. */ - attach?(endpoints: DeliveryEndpoints): void; - /** The transport's HTTP endpoint, when it delivers through one. */ - createDeliveryHandler?(): (request: Request) => Promise; + /** The HTTP endpoint the transport delivers each job to. */ + createDeliveryHandler(send: SendJob): (request: Request) => Promise; } diff --git a/packages/mcp-toolkit/src/events/webhooks.ts b/packages/mcp-toolkit/src/events/webhooks.ts index 8de2e71..a8ad78e 100644 --- a/packages/mcp-toolkit/src/events/webhooks.ts +++ b/packages/mcp-toolkit/src/events/webhooks.ts @@ -13,21 +13,18 @@ import { } from "../shared/crypto.js"; /** The secret length the MCP Events draft allows, in decoded bytes. */ -export const SECRET_MIN_BYTES = 24; -export const SECRET_MAX_BYTES = 64; +const SECRET_MIN_BYTES = 24; +const SECRET_MAX_BYTES = 64; +/** The least random key material `SecretBox` accepts, in decoded bytes. */ +const SECRET_KEY_MIN_BYTES = 32; /** The key bytes of a `whsec_` secret, or `null` when malformed or outside 24–64 bytes. */ export function decodeSecret(secret: unknown): Uint8Array | null { if (typeof secret !== "string" || !secret.startsWith("whsec_")) return null; - const body = secret.slice("whsec_".length); - if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return null; - let bytes: Uint8Array; - try { - bytes = fromBase64(body); - } catch { - return null; - } - return bytes.length >= SECRET_MIN_BYTES && bytes.length <= SECRET_MAX_BYTES ? bytes : null; + const bytes = decodeBase64(secret.slice("whsec_".length)); + return bytes && bytes.length >= SECRET_MIN_BYTES && bytes.length <= SECRET_MAX_BYTES + ? bytes + : null; } /** @@ -79,14 +76,18 @@ type AesKey = Awaited>; /** * Encrypts the hosts' signing secrets with a server-side key (AES-256-GCM), so a leaked database - * cannot forge events. Any string works as the key; it is hashed to 32 bytes. Rotating it makes - * stored secrets unreadable, and hosts re-verify on their next refresh. + * cannot forge events. The key is base64 of at least 32 random bytes (`openssl rand -base64 32`). + * Rotating it makes stored secrets unreadable, and hosts re-verify on their next refresh. */ export class SecretBox { private readonly key: Promise; constructor(secretKey: string) { - if (!secretKey) throw new Error("SecretBox needs a non-empty secretKey"); + if ((decodeBase64(secretKey)?.length ?? 0) < SECRET_KEY_MIN_BYTES) { + throw new Error( + "The events secretKey must be base64 of at least 32 random bytes. Generate one with: openssl rand -base64 32", + ); + } this.key = sha256(secretKey).then((raw) => crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]), ); @@ -102,22 +103,27 @@ export class SecretBox { return `v1.${toBase64(iv)}.${toBase64(new Uint8Array(sealed))}`; } - async open(sealed: string): Promise { + /** The secret, or `null` when it was sealed under another key or has been tampered with. */ + async open(sealed: string): Promise { const [version, iv, payload] = sealed.split("."); - if (version !== "v1" || !iv || !payload) throw new Error("Unrecognized sealed secret"); - const plain = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: fromBase64(iv) }, - await this.key, - fromBase64(payload), - ); - return fromUtf8(new Uint8Array(plain)); + if (version !== "v1" || !iv || !payload) return null; + try { + const plain = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: fromBase64(iv) }, + await this.key, + fromBase64(payload), + ); + return fromUtf8(new Uint8Array(plain)); + } catch { + return null; + } } } /** * Why a callback URL is refused, or `null` when it is acceptable. Refuses non-HTTPS, credentials, - * and hosts on the server's own network: `localhost`, single-label, `.local` / `.internal`, and - * private or reserved IP literals. It does not resolve DNS, so add egress filtering in production. + * every IP literal, and names on the server's own network: `localhost`, single-label hosts, + * `.local` and `.internal`. It does not resolve DNS, so add egress filtering in production. */ export function callbackUrlProblem(raw: unknown, allowInsecure = false): string | null { if (typeof raw !== "string") return "callback URL is missing"; @@ -135,78 +141,23 @@ export function callbackUrlProblem(raw: unknown, allowInsecure = false): string if (url.protocol !== "https:") return "callback URL must use https"; if (url.username || url.password) return "callback URL must not contain credentials"; - // `localhost.` resolves like `localhost`, so trailing dots go before any check. + // `localhost.` resolves like `localhost`, so trailing dots go before any check. The URL parser + // has already normalized every IPv4 form (hex, octal, short) to dotted decimal. const host = url.hostname.toLowerCase().replace(/\.+$/, ""); - if (host.startsWith("[")) { - return isPrivateV6(host.slice(1, -1)) ? "callback URL must be a public host" : null; - } - // The URL parser has already normalized every IPv4 form (hex, octal, short) to dotted decimal. - if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) { - return isPrivateV4(host.split(".").map(Number)) ? "callback URL must be a public host" : null; - } + const ipLiteral = host.startsWith("[") || /^\d+\.\d+\.\d+\.\d+$/.test(host); if ( + ipLiteral || !host.includes(".") || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal") ) { - return "callback URL must be a public host"; + return "callback URL must be a public host name"; } return null; } -function isPrivateV4([a = 0, b = 0, c = 0]: number[]): boolean { - return ( - a === 0 || - a === 10 || - a === 127 || - (a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT - (a === 169 && b === 254) || - (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) || - (a === 192 && b === 0 && (c === 0 || c === 2)) || // IETF protocol assignments, TEST-NET-1 - (a === 198 && (b === 18 || b === 19)) || // benchmarking - (a === 198 && b === 51 && c === 100) || // TEST-NET-2 - (a === 203 && b === 0 && c === 113) || // TEST-NET-3 - a >= 224 // multicast and reserved - ); -} - -function isPrivateV6(ip: string): boolean { - const g = expandV6(ip); - if (!g) return true; // unparseable: refuse - const v4 = (hi: number, lo: number) => isPrivateV4([hi >> 8, hi & 255, lo >> 8, lo & 255]); - const zero = (from: number, to: number) => g.slice(from, to).every((x) => x === 0); - if (zero(0, 5) && g[5] === 0xffff) return v4(g[6]!, g[7]!); // IPv4-mapped - if (zero(0, 6)) return v4(g[6]!, g[7]!); // IPv4-compatible, plus :: and ::1 - if (g[0] === 0x64 && g[1] === 0xff9b) { - if (zero(2, 6)) return v4(g[6]!, g[7]!); // NAT64 64:ff9b::/96 - if (g[2] === 1) return true; // local-use NAT64 64:ff9b:1::/48 - } - if (g[0] === 0x2002) return v4(g[1]!, g[2]!); // 6to4 - return ( - (g[0]! & 0xfe00) === 0xfc00 || // unique local fc00::/7 - (g[0]! & 0xffc0) === 0xfe80 || // link-local fe80::/10 - (g[0]! & 0xff00) === 0xff00 || // multicast ff00::/8 - (g[0] === 0x2001 && g[1] === 0x0db8) // documentation 2001:db8::/32 - ); -} - -/** Expands a compressed IPv6 address (as the URL parser prints it) to eight 16-bit groups. */ -function expandV6(ip: string): number[] | null { - const halves = ip.split("::"); - if (halves.length > 2) return null; - const parse = (part: string | undefined) => - part ? part.split(":").map((x) => (/^[0-9a-f]{1,4}$/.test(x) ? parseInt(x, 16) : NaN)) : []; - const head = parse(halves[0]); - const tail = parse(halves[1]); - const missing = 8 - head.length - tail.length; - if (halves.length === 1 ? missing !== 0 : missing < 0) return null; - const groups = [...head, ...Array(Math.max(missing, 0)).fill(0), ...tail]; - return groups.some(Number.isNaN) ? null : groups; -} - /** POSTs a signed JSON body to a callback, never following redirects. */ export async function postSigned(options: { url: string; @@ -214,14 +165,12 @@ export async function postSigned(options: { webhookId: string; body: string; subscriptionId: string; - timeoutMs: number; - fetch?: typeof fetch; }): Promise { const headers = await signWebhook(options.secret, options.webhookId, options.body); - return await (options.fetch ?? fetch)(options.url, { + return await fetch(options.url, { method: "POST", redirect: "manual", - signal: AbortSignal.timeout(options.timeoutMs), + signal: AbortSignal.timeout(CALLBACK_TIMEOUT_MS), headers: { "content-type": "application/json", "x-mcp-subscription-id": options.subscriptionId, @@ -231,6 +180,9 @@ export async function postSigned(options: { }); } +/** How long a callback gets to answer one POST. */ +const CALLBACK_TIMEOUT_MS = 10_000; + /** Reads at most `maxBytes` of a response body, then drops the rest. */ export async function readCapped(response: Response, maxBytes: number): Promise { const reader = response.body?.getReader(); @@ -257,3 +209,13 @@ export async function readCapped(response: Response, maxBytes: number): Promise< } return fromUtf8(bytes); } + +/** Strict standard base64, or `null`. */ +function decodeBase64(value: string): Uint8Array | null { + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null; + try { + return fromBase64(value); + } catch { + return null; + } +} diff --git a/packages/mcp-toolkit/src/shared/auth.ts b/packages/mcp-toolkit/src/shared/auth.ts index 71d755d..bddae88 100644 --- a/packages/mcp-toolkit/src/shared/auth.ts +++ b/packages/mcp-toolkit/src/shared/auth.ts @@ -1,21 +1,12 @@ -/** - * The SDK's `AuthInfo`, typed structurally so a principal resolver needs no SDK import. It is what - * your route passes to `handler.fetch(request, { authInfo })` after verifying the token: the SDK - * never fills it from headers itself. - */ -export type CallerAuth = { - token?: string; - clientId?: string; - scopes?: string[]; - expiresAt?: number; - extra?: Record; - [key: string]: unknown; -}; +import type { AuthInfo } from "@modelcontextprotocol/server"; /** What `principal` is handed for each call. */ export type Caller = { - /** The verified `AuthInfo` your route passed to the SDK, if any. Prefer this. */ - auth: CallerAuth | undefined; + /** + * The SDK's `AuthInfo`: what your route passed to `handler.fetch(request, { authInfo })` after + * verifying the token, if anything. The SDK never fills it from headers. Prefer this. + */ + auth: AuthInfo | undefined; /** * The raw HTTP request, for apps that authenticate with a cookie or session. It is unverified: * check the session yourself, and never trust a header like `x-user-id` the caller can set. @@ -23,51 +14,33 @@ export type Caller = { request: Request | undefined; }; -/** - * The caller's id, or the id plus non-secret context (an org id, a role) that event subscriptions - * store and hand back to `authorize` before each delivery. Never put the token in it. - */ -export type Principal = string | { id: string; context?: Record }; - /** * Maps a call to a stable caller id, usually your user id. It must return one: when it cannot * identify the caller, throw, and the call is refused as not authenticated. May be async, e.g. to * look up a session. */ -export type PrincipalResolver = (caller: Caller) => Principal | Promise; - -/** The most JSON a principal's context may take, since it is stored with every subscription. */ -export const MAX_CONTEXT_BYTES = 4096; +export type PrincipalResolver = (caller: Caller) => string | Promise; /** The auth and request an SDK request handler context carries. */ export function callerOf(context: unknown): Caller { - const http = (context as { http?: { authInfo?: CallerAuth; req?: Request } } | undefined)?.http; + const http = (context as { http?: { authInfo?: AuthInfo; req?: Request } } | undefined)?.http; return { auth: http?.authInfo, request: http?.req }; } /** - * Runs the resolver and normalizes its answer to `{ id, context }`. Fails closed: a throw, or an - * answer without a non-empty string id (from an `as string` that lied, or plain JavaScript), - * gives `undefined`, which the layers refuse as not authenticated. + * Runs the resolver. Fails closed: a throw, or anything but a non-empty string (an `as string` + * that lied, or plain JavaScript), gives `undefined`, which the layers refuse as not authenticated. */ export async function resolvePrincipal( principal: PrincipalResolver, caller: Caller, -): Promise<{ id: string; context: Record } | undefined> { - let result: unknown; +): Promise { try { - result = await principal(caller); + const id: unknown = await principal(caller); + return typeof id === "string" && id !== "" ? id : undefined; } catch { return undefined; } - const id = typeof result === "string" ? result : (result as { id?: unknown } | null)?.id; - if (typeof id !== "string" || id === "") return undefined; - if (typeof result === "string") return { id, context: {} }; - const context = (result as { context?: Record }).context ?? {}; - if (new TextEncoder().encode(JSON.stringify(context)).length > MAX_CONTEXT_BYTES) { - throw new Error(`principal context must be at most ${MAX_CONTEXT_BYTES} bytes of JSON`); - } - return { id, context }; } /** Throws at construction when a layer is built without a principal. */ diff --git a/packages/mcp-toolkit/src/shared/clients.ts b/packages/mcp-toolkit/src/shared/clients.ts index 372c1cf..10b3218 100644 --- a/packages/mcp-toolkit/src/shared/clients.ts +++ b/packages/mcp-toolkit/src/shared/clients.ts @@ -5,6 +5,9 @@ import { Redis } from "@upstash/redis"; import { Client as QStashClient, Receiver } from "@upstash/qstash"; import { addQStashTelemetry, addTelemetry } from "../telemetry.js"; +import { env } from "./env.js"; + +export { INTERNAL_ERROR, env } from "./env.js"; /** Calls `create` once, on first access. */ export function lazy(create: () => T): () => T { @@ -59,10 +62,6 @@ function redisFromEnv(owner: string): Redis { }); } -export function env(name: string): string | undefined { - return typeof process === "object" ? process.env?.[name] : undefined; -} - export function requireEnv(owner: string, name: string): string { const value = env(name); if (!value) throw new Error(`${owner} needs ${name} (or pass the client explicitly).`); @@ -76,3 +75,38 @@ export function requireEnv(owner: string, name: string): string { export function nonRetryable(message: string): Response { return new Response(message, { status: 489, headers: { "Upstash-NonRetryable-Error": "true" } }); } + +/** + * A receiver that always checks the signature was issued for `url`. Upstash Workflow verifies + * with only the body and signature, which would accept a delivery signed for any other endpoint. + */ +export function boundToUrl(receiver: Receiver, url: string): Receiver { + return Object.assign(Object.create(receiver) as Receiver, { + verify: (request: Parameters[0]) => receiver.verify({ ...request, url }), + }); +} + +/** + * Reads a QStash delivery: verifies its signature against `url`, the URL we published to (behind + * a proxy `request.url` is the internal one), then parses the JSON body. Anything wrong comes back + * as a non-retryable response. + */ +export async function readQStashJson( + request: Request, + receiver: Receiver, + url: string, +): Promise { + const body = await request.text(); + try { + const signature = request.headers.get("upstash-signature") ?? ""; + if (!(await receiver.verify({ signature, body, url }))) + return nonRetryable("invalid signature"); + } catch { + return nonRetryable("invalid signature"); + } + try { + return JSON.parse(body) as unknown; + } catch { + return nonRetryable("malformed body"); + } +} diff --git a/packages/mcp-toolkit/src/shared/env.ts b/packages/mcp-toolkit/src/shared/env.ts new file mode 100644 index 0000000..b878bed --- /dev/null +++ b/packages/mcp-toolkit/src/shared/env.ts @@ -0,0 +1,9 @@ +/** Dependency-free helpers the generic layers share with the Upstash backends. */ + +/** JSON-RPC internal error. */ +export const INTERNAL_ERROR = -32603; + +/** An environment variable, or `undefined` where there is no `process` (edge runtimes). */ +export function env(name: string): string | undefined { + return typeof process === "object" ? process.env?.[name] : undefined; +} diff --git a/packages/mcp-toolkit/src/tasks/backends/memory.ts b/packages/mcp-toolkit/src/tasks/backends/memory.ts deleted file mode 100644 index 9c66f4b..0000000 --- a/packages/mcp-toolkit/src/tasks/backends/memory.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Single-process backends for tests and a first local run. Nothing here survives a restart, and - * {@link InlineTaskDispatcher} runs work in the process that took the tool call. - */ -import { - isTerminal, - UnknownTaskError, - type SettleResult, - 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 { - const existing = this.tasks.get(task.taskId); - if (existing) return { ...existing }; - this.tasks.set(task.taskId, { ...task }); - if (task.ttlMs !== null && task.ttlMs > 0) { - // Stands in for Redis' expiry. Unref'd so it 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); - } - return null; - } - - async get(taskId: string): Promise { - const task = this.tasks.get(taskId); - return task ? { ...task } : null; - } - - async update(taskId: string, patch: TaskPatch): Promise { - const outcome = this.write(taskId, patch); - if (!outcome) throw new UnknownTaskError(taskId); - return outcome.task; - } - - async settle(taskId: string, patch: TerminalTaskPatch): Promise { - return this.write(taskId, patch); - } - - /** Drops every task and its pending expiry. */ - clear(): void { - for (const timer of this.timers.values()) clearTimeout(timer); - this.timers.clear(); - this.tasks.clear(); - } - - /** Synchronous, so nothing interleaves between the check and the write. */ - private write(taskId: string, patch: TaskPatch): SettleResult | null { - const task = this.tasks.get(taskId); - if (!task) return null; - if (isTerminal(task.status)) return { task: { ...task }, settled: false }; - const next: Task = { ...task, ...patch, lastUpdatedAt: new Date().toISOString() }; - this.tasks.set(taskId, next); - return { task: { ...next }, settled: true }; - } -} - -/** - * Runs a task in the current process, on the next microtask. Nothing is durable and `cancel` is a - * no-op, so stopping relies on the handler checking `isCancelled()`. A throw fails the task: there - * are no retries. - */ -export class InlineTaskDispatcher implements TaskDispatcher { - private readonly pending = new Set>(); - private endpoints: TaskEndpoints | undefined; - - attach(endpoints: TaskEndpoints): void { - this.endpoints = endpoints; - } - - async dispatch(task: Task): Promise { - const endpoints = this.endpoints; - if (!endpoints) { - throw new Error("InlineTaskDispatcher is not attached — pass it to createTaskLayer()."); - } - const run = Promise.resolve() - .then(() => endpoints.run(task.taskId, undefined)) - .then( - () => undefined, - (cause: unknown) => - endpoints - .fail(task.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 {} - - /** Resolves once every dispatched task has settled. */ - async drain(): Promise { - while (this.pending.size > 0) await Promise.all([...this.pending]); - } -} diff --git a/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts b/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts index d214cf3..eb0a0d0 100644 --- a/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts +++ b/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts @@ -1,12 +1,19 @@ 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"; +import type { Task, TaskError } from "../types.js"; +import { + cleanupKeys, + hasRedisCreds, + qstashRequest, + testReceiver, + 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)}`, + taskId: crypto.randomUUID(), status: "working", statusMessage: "Queued for durable execution", createdAt: now, @@ -39,17 +46,12 @@ describe.skipIf(!hasRedisCreds)("RedisTaskStore (real Redis)", () => { await store.create(task); const loaded = await store.get(task.taskId); - expect(loaded).not.toBeNull(); - expect(loaded?.statusMessage).toBe("123"); + expect(loaded).toEqual(task); 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(); + expect(await store.get(crypto.randomUUID())).toBeNull(); }); it("sets a TTL from ttlMs, and update does not extend it", async () => { @@ -68,28 +70,16 @@ describe.skipIf(!hasRedisCreds)("RedisTaskStore (real Redis)", () => { 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, - ); + await store.update(task.taskId, { statusMessage: "Step 2/4" }); + const updated = await store.get(task.taskId); + 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("settles a working task and refuses every settle after it", async () => { @@ -101,15 +91,13 @@ describe.skipIf(!hasRedisCreds)("RedisTaskStore (real Redis)", () => { statusMessage: "Completed", result: { content: [{ type: "text", text: "done" }] }, }); - expect(completed?.settled).toBe(true); - expect(completed?.task.status).toBe("completed"); - expect(completed?.task.result).toEqual({ 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, and gets the // current record back in the same round trip. const cancelled = await store.settle(task.taskId, { status: "cancelled" }); - expect(cancelled?.settled).toBe(false); - expect(cancelled?.task.status).toBe("completed"); + expect(cancelled?.status).toBe("completed"); expect((await store.get(task.taskId))?.status).toBe("completed"); }); @@ -117,18 +105,15 @@ describe.skipIf(!hasRedisCreds)("RedisTaskStore (real Redis)", () => { const task = makeTask(); await store.create(task); - expect((await store.settle(task.taskId, { status: "cancelled" }))?.task.status).toBe( - "cancelled", - ); + 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: {} }))?.settled).toBe( - false, + expect((await store.settle(task.taskId, { status: "completed", result: {} }))?.status).toBe( + "cancelled", ); - 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(); + expect(await store.settle(crypto.randomUUID(), { status: "completed" })).toBeNull(); }); it("ignores an update to a task that already finished", async () => { @@ -137,44 +122,17 @@ describe.skipIf(!hasRedisCreds)("RedisTaskStore (real Redis)", () => { 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("creates only if absent, and hands back the existing task otherwise", async () => { - const task = makeTask({ statusMessage: "first" }); - expect(await store.create(task)).toBeNull(); + await store.update(task.taskId, { statusMessage: "Attempt failed: too late" }); - const again = await store.create({ ...task, statusMessage: "second" }); - expect(again?.taskId).toBe(task.taskId); - expect(again?.statusMessage).toBe("first"); - expect((await store.get(task.taskId))?.statusMessage).toBe("first"); - }); - - it("returns the record from update and settle in the same round trip", async () => { - const task = makeTask(); - await store.create(task); - const updated = await store.update(task.taskId, { statusMessage: "42" }); - expect(updated.statusMessage).toBe("42"); - expect(updated.args).toEqual(task.args); - - const settled = await store.settle(task.taskId, { - status: "completed", - result: { content: [{ type: "text", text: "ok" }] }, - }); - expect(settled?.task.status).toBe("completed"); - expect(settled?.task.result).toEqual({ content: [{ type: "text", text: "ok" }] }); - // A finished task's update returns it unchanged rather than failing. - expect((await store.update(task.taskId, { statusMessage: "late" })).statusMessage).toBe("42"); + const after = await store.get(task.taskId); + 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); + const ghost = crypto.randomUUID(); + await store.update(ghost, { statusMessage: "x" }); + expect(await redis.exists(prefix + ghost)).toBe(0); }); }); @@ -210,44 +168,26 @@ describe("constructing without credentials", () => { }); describe("QStashDispatcher.createExecuteHandler", () => { - /** A Receiver stand-in: the real one needs live signing keys, and we are testing our own gate. */ - const receiver = (accept: boolean) => - ({ - verify: async () => { - if (!accept) throw new Error("bad signature"); - return true; - }, - }) as unknown as ConstructorParameters[0]["receiver"]; + const URL = "https://public.example.com/api/execute"; 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; + /** A handler over a real Receiver, plus a record of what it called back into. */ + const connected = (options: { throws?: boolean } = {}) => { const calls: Calls = { ran: [], failed: [] }; - const dispatcher = new QStashDispatcher({ - url: "https://example.com/api/execute", - receiver: receiver(accept), - }); - dispatcher.attach({ + const dispatcher = new QStashDispatcher({ url: URL, receiver: testReceiver() }); + const handler = dispatcher.createExecuteHandler({ run: async (taskId: string) => { calls.ran.push(taskId); - if (throws) throw new Error("boom"); + if (options.throws) throw new Error("boom"); }, fail: async (taskId: string, error: TaskError) => { calls.failed.push({ taskId, error }); }, }); - return { handler: dispatcher.createExecuteHandler(), calls }; + return { handler, 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"); @@ -256,28 +196,41 @@ describe("QStashDispatcher.createExecuteHandler", () => { delete process.env.QSTASH_CURRENT_SIGNING_KEY; delete process.env.QSTASH_NEXT_SIGNING_KEY; try { - const dispatcher = new QStashDispatcher({ url: "https://example.com/api/execute" }); - dispatcher.attach({ run: async () => undefined, fail: async () => undefined }); - await expect(dispatcher.createExecuteHandler()(deliver({ taskId: "t" }))).rejects.toThrow( - /signing keys/, - ); + const handler = new QStashDispatcher({ url: URL }).createExecuteHandler({ + run: async () => undefined, + fail: async () => undefined, + }); + await expect(handler(qstashRequest(URL, { taskId: "t" }))).rejects.toThrow(/signing keys/); } finally { process.env = saved; } }); - it("runs a delivery and acknowledges with 200", async () => { - const { handler, calls } = attached(); - const response = await handler(deliver({ taskId: "t1" })); + it("runs a signed delivery and acknowledges with 200", async () => { + const { handler, calls } = connected(); + const response = await handler(qstashRequest(URL, { taskId: "t1" })); expect(response.status).toBe(200); expect(calls.ran).toEqual(["t1"]); expect(calls.failed).toEqual([]); }); + it("verifies against the published URL, so it works behind a proxy", async () => { + const { handler, calls } = connected(); + // The request arrives on an internal URL, but QStash signed the public destination. + const signed = qstashRequest(URL, { taskId: "t1" }); + const proxied = new Request("http://10.0.0.5:3000/api/execute", { + method: "POST", + headers: signed.headers, + body: await signed.text(), + }); + expect((await handler(proxied)).status).toBe(200); + expect(calls.ran).toEqual(["t1"]); + }); + it("answers 500 so QStash retries, without failing the task", async () => { - const { handler, calls } = attached({ throws: true }); - const response = await handler(deliver({ taskId: "t1" })); + const { handler, calls } = connected({ throws: true }); + const response = await handler(qstashRequest(URL, { taskId: "t1" })); expect(response.status).toBe(500); // The transport has attempts left; nothing here decides the task has failed. @@ -285,10 +238,10 @@ describe("QStashDispatcher.createExecuteHandler", () => { }); it("settles the task failed when the failure callback arrives", async () => { - const { handler, calls } = attached(); + const { handler, calls } = connected(); // The shape QStash posts once every retry is exhausted. const response = await handler( - deliver({ + qstashRequest(URL, { sourceBody: base64({ taskId: "t1" }), sourceMessageId: "msg_1", status: 500, @@ -314,37 +267,49 @@ describe("QStashDispatcher.createExecuteHandler", () => { }); }); - it("rejects an unsigned delivery as non-retryable and never runs the task", async () => { - const { handler, calls } = attached({ accept: false }); - const response = await handler(deliver({ taskId: "t1" })); - - // QStash retries every non-2xx except this one, and a retry cannot fix a bad signature. - expect(response.status).toBe(489); - expect(response.headers.get("Upstash-NonRetryable-Error")).toBe("true"); - expect(calls.ran).toEqual([]); - expect(calls.failed).toEqual([]); + describe("refuses, as non-retryable, and never runs or fails a task", () => { + const cases: [string, Request][] = [ + [ + "an unsigned delivery", + new Request(URL, { method: "POST", body: JSON.stringify({ taskId: "t1" }) }), + ], + ["a signature from another key", qstashRequest(URL, { taskId: "t1" }, { key: "stolen" })], + [ + "a signature issued for another endpoint", + qstashRequest(URL, { taskId: "t1" }, { sub: "https://public.example.com/api/other" }), + ], + [ + "a body the signature does not cover", + qstashRequest(URL, { taskId: "t1" }, { body: "aGFzaCBvZiBzb21ldGhpbmcgZWxzZQ" }), + ], + [ + "an expired signature", + qstashRequest(URL, { taskId: "t1" }, { exp: Math.floor(Date.now() / 1000) - 600 }), + ], + ]; + for (const [label, request] of cases) { + it(label, async () => { + const { handler, calls } = connected(); + const response = await handler(request.clone()); + // QStash retries every non-2xx except this one, and a retry cannot fix a bad signature. + expect(response.status).toBe(489); + expect(response.headers.get("Upstash-NonRetryable-Error")).toBe("true"); + expect(calls).toEqual({ ran: [], failed: [] }); + }); + } }); - it("rejects a body that is neither a delivery nor a failure callback", async () => { - const { handler } = attached(); - expect((await handler(deliver({}))).status).toBe(489); - expect( - ( - await handler( - new Request("https://internal.example/api/execute", { - method: "POST", - headers: { "upstash-signature": "sig" }, - body: "not json", - }), - ) - ).status, - ).toBe(489); + it("rejects a signed body that is neither a delivery nor a failure callback", async () => { + const { handler, calls } = connected(); + expect((await handler(qstashRequest(URL, {}))).status).toBe(489); + expect((await handler(qstashRequest(URL, "not json"))).status).toBe(489); + expect(calls).toEqual({ ran: [], failed: [] }); }); it("decodes a non-ASCII failure response as UTF-8", async () => { - const { handler, calls } = attached(); + const { handler, calls } = connected(); await handler( - deliver({ + qstashRequest(URL, { sourceBody: base64({ taskId: "t1" }), status: 502, body: Buffer.from("Ağ geçidi hatası ✗", "utf8").toString("base64"), @@ -352,40 +317,10 @@ describe("QStashDispatcher.createExecuteHandler", () => { ); expect(calls.failed[0]?.error.data).toMatchObject({ response: "Ağ geçidi hatası ✗" }); }); - - 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/, - ); - }); }); describe("QStashDispatcher.dispatch", () => { - it("deduplicates per task record, not per task id", async () => { + it("deduplicates on the task id and publishes only the id", async () => { const published: Record[] = []; const qstash = { publishJSON: async (options: Record) => { @@ -395,9 +330,11 @@ describe("QStashDispatcher.dispatch", () => { } as unknown as ConstructorParameters[0]["qstash"]; const dispatcher = new QStashDispatcher({ url: "https://example.com/api/execute", qstash }); - expect(await dispatcher.dispatch(makeTask({ taskId: "t" }))).toBe("msg_1"); + const task = makeTask(); + await dispatcher.dispatch(task); // Deduplicated on the task id, so a double dispatch of one task is delivered once. - expect(published[0]?.deduplicationId).toBe("t"); - expect(published[0]?.body).toEqual({ taskId: "t" }); + expect(published[0]?.deduplicationId).toBe(task.taskId); + expect(published[0]?.body).toEqual({ taskId: task.taskId }); + expect(published[0]?.failureCallback).toBe("https://example.com/api/execute"); }); }); diff --git a/packages/mcp-toolkit/src/tasks/backends/qstash.ts b/packages/mcp-toolkit/src/tasks/backends/qstash.ts index e2566cb..2fd5b78 100644 --- a/packages/mcp-toolkit/src/tasks/backends/qstash.ts +++ b/packages/mcp-toolkit/src/tasks/backends/qstash.ts @@ -2,39 +2,33 @@ import type { Redis } from "@upstash/redis"; import type { Client as QStashClient, Receiver } from "@upstash/qstash"; import { + INTERNAL_ERROR, lazy, nonRetryable, + readQStashJson, resolveQStash, resolveReceiver, resolveRedis, } from "../../shared/clients.js"; import { fromBase64, fromUtf8 } from "../../shared/crypto.js"; -import { - UnknownTaskError, - type SettleResult, - type Task, - type TaskDispatcher, - type TaskEndpoints, - type TaskError, - type TaskPatch, - type TaskStore, - type TerminalTaskPatch, +import type { + Task, + TaskDispatcher, + TaskEndpoints, + TaskError, + TaskPatch, + TaskStore, + TerminalTaskStatus, } from "../types.js"; -/** JSON-RPC internal error. */ -const INTERNAL_ERROR = -32603; - -/** Default key prefix for task hashes: `mcp:task:`. */ -export const DEFAULT_TASK_PREFIX = "mcp:task:"; - /** * Backoff between deliveries: 1s, 3s, 9s, 27s, 81s. About two minutes over the default retries, * long enough to outlast a deploy or a crash loop. */ -export const DEFAULT_RETRY_DELAY = "min(pow(3, retried) * 1000, 300000)"; +const DEFAULT_RETRY_DELAY = "min(pow(3, retried) * 1000, 300000)"; /** Delivery retries. 5 is the most QStash's free tier and local dev server accept. */ -export const DEFAULT_RETRIES = 5; +const DEFAULT_RETRIES = 5; export type RedisTaskStoreConfig = { /** @@ -42,36 +36,23 @@ export type RedisTaskStoreConfig = { * built with `automaticDeserialization: false` is not supported. */ redis?: Redis; - /** Key prefix. Defaults to {@link DEFAULT_TASK_PREFIX}. */ + /** Key prefix. Defaults to `mcp:task:`. */ prefix?: string; /** Set `false` to skip this package's tag in the telemetry header. */ enableTelemetry?: boolean; }; -/** Creates the hash and its TTL unless the key exists, in which case it returns the existing fields. */ -const CREATE_SCRIPT = `#!lua flags=allow-key-locking -if redis.call('EXISTS', KEYS[1]) == 1 then return redis.call('HGETALL', KEYS[1]) end -redis.call('HSET', KEYS[1], unpack(ARGV, 2)) -local ttl = tonumber(ARGV[1]) -if ttl > 0 then redis.call('PEXPIRE', KEYS[1], ttl) end -return false -`; - /** - * Applies fields only while the task is non-terminal. Returns `[changed, ...fields]`, or nil when - * the task is missing. Values are JSON-encoded, hence the quoted statuses. + * Applies fields only while the task is non-terminal, and returns `HGETALL` after the call, or nil + * when the task is missing. Values are JSON-encoded, hence the quoted statuses. */ const GUARDED_WRITE_SCRIPT = `#!lua flags=allow-key-locking local status = redis.call('HGET', KEYS[1], 'status') if not status then return false end -local changed = 0 if status ~= '"completed"' and status ~= '"failed"' and status ~= '"cancelled"' then redis.call('HSET', KEYS[1], unpack(ARGV)) - changed = 1 end -local fields = redis.call('HGETALL', KEYS[1]) -table.insert(fields, 1, changed) -return fields +return redis.call('HGETALL', KEYS[1]) `; /** @@ -83,50 +64,46 @@ export class RedisTaskStore implements TaskStore { private readonly redis: () => Redis; constructor(config: RedisTaskStoreConfig = {}) { - this.prefix = config.prefix ?? DEFAULT_TASK_PREFIX; + this.prefix = config.prefix ?? "mcp:task:"; this.redis = lazy(() => resolveRedis("RedisTaskStore", config.redis, config.enableTelemetry)); } - async create(task: Task): Promise { - const ttl = task.ttlMs !== null && task.ttlMs > 0 ? task.ttlMs : 0; - const existing = await this.redis().eval( - CREATE_SCRIPT, - [this.key(task.taskId)], - [String(ttl), ...toArgs(task)], - ); - return toTask(existing); + async create(task: Task): Promise { + const key = this.key(task.taskId); + await this.redis().multi().hset(key, toFields(task)).pexpire(key, task.ttlMs).exec(); } async get(taskId: string): Promise { const fields = await this.redis().hgetall>(this.key(taskId)); - return fields && Object.keys(fields).length > 0 ? fromFields(fields) : null; + return fields && Object.keys(fields).length > 0 ? (fields as Task) : null; } - async update(taskId: string, patch: TaskPatch): Promise { - const outcome = await this.guardedWrite(taskId, patch); - if (!outcome) throw new UnknownTaskError(taskId); - return outcome.task; + async update(taskId: string, patch: TaskPatch): Promise { + await this.guardedWrite(taskId, patch); } - async settle(taskId: string, patch: TerminalTaskPatch): Promise { + async settle( + taskId: string, + patch: TaskPatch & { status: TerminalTaskStatus }, + ): Promise { return await this.guardedWrite(taskId, patch); } - /** The Redis key a task is stored under. */ - key(taskId: string): string { + private key(taskId: string): string { return this.prefix + taskId; } - private async guardedWrite(taskId: string, patch: TaskPatch): Promise { + private async guardedWrite(taskId: string, patch: TaskPatch): Promise { + const fields = toFields({ ...patch, lastUpdatedAt: new Date().toISOString() }); const reply = await this.redis().eval( GUARDED_WRITE_SCRIPT, [this.key(taskId)], - toArgs({ ...patch, lastUpdatedAt: new Date().toISOString() }), + Object.entries(fields).flat(), ); - if (!Array.isArray(reply)) return null; - const [changed, ...fields] = reply; - const task = toTask(fields); - return task ? { task, settled: Number(changed) === 1 } : null; + if (!Array.isArray(reply) || reply.length === 0) return null; + const task: Record = {}; + for (let i = 0; i + 1 < reply.length; i += 2) task[String(reply[i])] = reply[i + 1]; + return task as Task; } } @@ -135,20 +112,18 @@ export type QStashDispatcherConfig = { url: string; /** The QStash client. Defaults to one from `QSTASH_TOKEN` (and `QSTASH_URL`). */ qstash?: QStashClient; - /** Delivery retries before QStash dead-letters a task. Defaults to {@link DEFAULT_RETRIES}. */ - retries?: number; - /** - * Backoff as a QStash delay expression. Defaults to {@link DEFAULT_RETRY_DELAY}. Size the total - * budget against your deploys: it has to outlast a restart. - */ - retryDelay?: string; - /** Extra headers to send with each delivery. */ - headers?: Record; /** * Verifies deliveries. Defaults to one from `QSTASH_CURRENT_SIGNING_KEY` / * `QSTASH_NEXT_SIGNING_KEY`. Required either way: without keys the endpoint refuses to serve. */ receiver?: Receiver; + /** Delivery retries before QStash dead-letters a task. Defaults to 5. */ + retries?: number; + /** + * Backoff as a QStash delay expression. Defaults to about two minutes over five retries. Size + * the total budget against your deploys: it has to outlast a restart. + */ + retryDelay?: string; /** Set `false` to skip this package's tag in the telemetry header. */ enableTelemetry?: boolean; }; @@ -162,7 +137,6 @@ export class QStashDispatcher implements TaskDispatcher { private readonly config: QStashDispatcherConfig; private readonly qstash: () => QStashClient; private readonly receiver: () => Receiver; - private endpoints: TaskEndpoints | undefined; constructor(config: QStashDispatcherConfig) { this.config = config; @@ -172,27 +146,20 @@ export class QStashDispatcher implements TaskDispatcher { this.receiver = lazy(() => resolveReceiver("QStashDispatcher", config.receiver)); } - attach(endpoints: TaskEndpoints): void { - this.endpoints = endpoints; - } - - async dispatch(task: Task): Promise { - const message = await this.qstash().publishJSON({ + async dispatch(task: Task): Promise { + await this.qstash().publishJSON({ url: this.config.url, body: { taskId: task.taskId }, retries: this.config.retries ?? DEFAULT_RETRIES, retryDelay: this.config.retryDelay ?? DEFAULT_RETRY_DELAY, - headers: this.config.headers, // Comes back to the same route once retries are exhausted, and settles the task `failed`. failureCallback: this.config.url, deduplicationId: task.taskId, }); - return Array.isArray(message) ? message[0]?.messageId : message.messageId; } - async cancel(dispatchId: string): Promise { - await this.qstash().messages.cancel(dispatchId); - } + /** Nothing to stop: a redelivery of a cancelled task finds it settled and does nothing. */ + async cancel(): Promise {} /** * The delivery endpoint. A delivery (`{ taskId }`) runs the task; a failure callback @@ -203,48 +170,25 @@ export class QStashDispatcher implements TaskDispatcher { * - **489** with `Upstash-NonRetryable-Error`: bad signature or body. QStash retries every other * non-2xx, so this is the only way to stop it. */ - createExecuteHandler(): (request: Request) => Promise { + createExecuteHandler(endpoints: TaskEndpoints): (request: Request) => Promise { return async (request: Request): Promise => { - const endpoints = this.endpoints; - if (!endpoints) { - throw new Error("QStashDispatcher is not attached — pass it to createTaskLayer()."); - } - // Outside the try: missing signing keys are a configuration error, not a bad signature. - const receiver = this.receiver(); - const body = await request.text(); - try { - // Against the URL we published to: behind a proxy, `request.url` is the internal one. - await receiver.verify({ - signature: request.headers.get("upstash-signature") ?? "", - body, - url: this.config.url, - }); - } catch { - return nonRetryable("invalid signature"); - } - - let payload: DeliveryBody; - try { - payload = JSON.parse(body) as DeliveryBody; - } catch { - return nonRetryable("malformed body"); - } + // Outside any try: missing signing keys are a configuration error, not a bad signature. + const payload = await readQStashJson(request, this.receiver(), this.config.url); + if (payload instanceof Response) return payload; + const body = (payload ?? {}) as DeliveryBody; - const failure = readFailureCallback(payload); + const failure = readFailureCallback(body); if (failure) { await endpoints.fail(failure.taskId, failure.error); return new Response("recorded"); } - if (!payload.taskId) return nonRetryable("missing taskId"); + if (typeof body.taskId !== "string") return nonRetryable("missing taskId"); try { - await endpoints.run(payload.taskId, undefined); + await endpoints.run(body.taskId, undefined); return new Response("ok"); } catch (error) { - console.error( - `[mcp-toolkit] task ${payload.taskId} failed, asking QStash to retry:`, - error, - ); + console.error(`[mcp-toolkit] task ${body.taskId} failed, asking QStash to retry:`, error); return new Response("retry", { status: 500 }); } }; @@ -270,14 +214,15 @@ function readFailureCallback( payload: DeliveryBody, ): { taskId: string; error: TaskError } | undefined { if (typeof payload.sourceBody !== "string") return undefined; - let taskId: string | undefined; + let taskId: unknown; try { - taskId = (JSON.parse(decodeBase64(payload.sourceBody)) as { taskId?: string }).taskId; + taskId = (JSON.parse(fromUtf8(fromBase64(payload.sourceBody))) as { taskId?: unknown }).taskId; } catch { return undefined; } - if (!taskId) return undefined; - const response = typeof payload.body === "string" ? decodeBase64(payload.body) : undefined; + if (typeof taskId !== "string") return undefined; + const response = + typeof payload.body === "string" ? fromUtf8(fromBase64(payload.body)) : undefined; return { taskId, error: { @@ -290,29 +235,14 @@ function readFailureCallback( }; } -const decodeBase64 = (value: string): string => fromUtf8(fromBase64(value)); - /** - * Hash fields as `HSET` arguments. Every value is JSON-encoded, so the client's automatic - * deserialization on read is its exact inverse (`"123"` stays a string). + * Hash fields for `HSET`. Every value is JSON-encoded, so the client's automatic deserialization + * on read is its exact inverse (`"123"` stays a string). */ -function toArgs(patch: Partial): string[] { - const args: string[] = []; +function toFields(patch: Partial): Record { + const fields: Record = {}; for (const [field, value] of Object.entries(patch)) { - if (value !== undefined) args.push(field, JSON.stringify(value ?? null)); + if (value !== undefined) fields[field] = JSON.stringify(value); } - return args; -} - -/** A flat field/value list from a script's `HGETALL`, as a task. */ -function toTask(reply: unknown[] | null | undefined): Task | null { - if (!Array.isArray(reply) || reply.length === 0) return null; - const fields: Record = {}; - for (let i = 0; i + 1 < reply.length; i += 2) fields[String(reply[i])] = reply[i + 1]; - return fromFields(fields); -} - -function fromFields(fields: Record): Task { - // `ttlMs: null` means unlimited, so an absent field reads as null too. - return { ttlMs: null, ...fields } as Task; + return fields; } diff --git a/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts b/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts index e4a951a..8f5274c 100644 --- a/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts +++ b/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts @@ -11,16 +11,23 @@ import { describe, expect, it } from "vitest"; import * as z from "zod"; import { insideStep, WorkflowDispatcher } from "./workflow.js"; import { createTaskLayer } from "../core.js"; -import { MemoryTaskStore } from "./memory.js"; import type { Task, TaskContext } from "../types.js"; -import { ManualDispatcher } from "../../test-support.js"; +import { + ManualDispatcher, + MemoryTaskStore, + qstashRequest, + testReceiver, +} from "../../test-support.js"; + +const URL = "https://example.com/api/workflow"; const task = (overrides: Partial = {}): Task => ({ taskId: "task-1", status: "working", createdAt: "2026-10-08T10:00:00.000Z", lastUpdatedAt: "2026-10-08T10:00:00.000Z", - ttlMs: null, + ttlMs: 60_000, + pollIntervalMs: 2_000, name: "demo", args: {}, owner: "local", @@ -53,7 +60,7 @@ describe("WorkflowDispatcher", () => { it("gives handlers the Workflow API without a type argument", () => { const tasks = createTaskLayer({ store: new MemoryTaskStore(), - dispatcher: new WorkflowDispatcher({ url: "https://example.com/api/workflow" }), + dispatcher: new WorkflowDispatcher({ url: URL }), principal: () => "local", }); // Compiles only if `task` is inferred as TaskContext & WorkflowContext. @@ -69,35 +76,27 @@ describe("WorkflowDispatcher", () => { ); }); - it("triggers a run named after the task record, so a double dispatch is deduplicated", async () => { + 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 dispatcher = new WorkflowDispatcher({ url: URL, client }); - const dispatchId = await dispatcher.dispatch(task()); + await dispatcher.dispatch(task()); expect(triggered).toHaveLength(1); - expect(triggered[0]?.url).toBe("https://example.com/api/workflow"); + expect(triggered[0]?.url).toBe(URL); expect(triggered[0]?.body).toEqual({ taskId: "task-1" }); 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 }); + const dispatcher = new WorkflowDispatcher({ url: URL, 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", () => { @@ -114,33 +113,29 @@ describe("the context a handler receives", () => { async sleep(): Promise {} } - /** Runs one task through the layer with whatever context the dispatcher would supply. */ - async function runWith( - context: TContext | undefined, + /** A layer with one `demo` task stored and ready to run. */ + async function layerWithTask( handler: (task: TaskContext & TContext) => Promise>, ) { const store = new MemoryTaskStore(); const dispatcher = new ManualDispatcher(); const tasks = createTaskLayer({ store, dispatcher, principal: () => "local" }); - - const now = new Date().toISOString(); - await store.create({ - taskId: "t1", - status: "working", - createdAt: now, - lastUpdatedAt: now, - ttlMs: null, - name: "demo", - args: {}, - owner: "local", - }); - + tasks.createExecuteHandler(); + await store.create(task({ taskId: "t1" })); tasks.define( "demo", { description: "d", inputSchema: { "~standard": {} } as never }, async (_args, task) => await handler(task), ); + return { store, dispatcher }; + } + /** 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, dispatcher } = await layerWithTask(handler); await dispatcher.run("t1", context); return await store.get("t1"); } @@ -174,29 +169,11 @@ describe("the context a handler receives", () => { 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 dispatcher = new ManualDispatcher(); - const tasks = createTaskLayer({ store, dispatcher, principal: () => "local" }); - const now = new Date().toISOString(); - await store.create({ - taskId: "t1", - status: "working", - createdAt: now, - lastUpdatedAt: now, - ttlMs: null, - name: "demo", - args: {}, - owner: "local", + const { dispatcher } = await layerWithTask(async (task) => { + await task.update("one"); + await task.update("two"); + return {}; }); - tasks.define( - "demo", - { description: "d", inputSchema: { "~standard": {} } as never }, - async (_args, task) => { - await task.update("one"); - await task.update("two"); - return {}; - }, - ); await dispatcher.run("t1", undefined, async (name, fn) => { journaled.push(name); @@ -206,8 +183,6 @@ describe("the context a handler receives", () => { // 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 () => { @@ -231,6 +206,13 @@ describe("the context a handler receives", () => { }); describe("WorkflowDispatcher signature verification", () => { + const endpoints = (ran: string[]) => ({ + run: async (taskId: string) => { + ran.push(taskId); + }, + fail: async () => undefined, + }); + it("refuses to serve without signing keys, instead of running unverified", async () => { // The Workflow SDK's own default is to skip verification when the keys are missing. const saved = { ...process.env }; @@ -238,15 +220,41 @@ describe("WorkflowDispatcher signature verification", () => { delete process.env.QSTASH_NEXT_SIGNING_KEY; process.env.QSTASH_TOKEN = "test-token"; try { - const dispatcher = new WorkflowDispatcher({ url: "https://example.com/api/workflow" }); - const handler = dispatcher.createExecuteHandler(); // building the route must not throw - expect(() => - handler(new Request("https://example.com/api/workflow", { method: "POST", body: "{}" })), - ).toThrow(/signing keys/); + const dispatcher = new WorkflowDispatcher({ url: URL }); + const handler = dispatcher.createExecuteHandler(endpoints([])); // building must not throw + expect(() => handler(new Request(URL, { method: "POST", body: "{}" }))).toThrow( + /signing keys/, + ); } finally { process.env = saved; } }); + + it("refuses a signature QStash issued for another endpoint", async () => { + // Workflow verifies with only body and signature. Without the URL bound in, a delivery signed + // for any other route of the same QStash account would be accepted here. + const receiver = testReceiver(); + const checkedUrls: (string | undefined)[] = []; + const verify = receiver.verify.bind(receiver); + receiver.verify = async (request) => { + checkedUrls.push(request.url); + return await verify(request); + }; + const ran: string[] = []; + const handler = new WorkflowDispatcher({ + url: URL, + receiver, + qstash: new QStashClient({ token: "test" }), + }).createExecuteHandler(endpoints(ran)); + + const response = await handler( + qstashRequest(URL, { taskId: "t1" }, { sub: "https://example.com/api/other" }), + ); + + expect(response.ok).toBe(false); + expect(ran).toEqual([]); + expect(checkedUrls).toEqual([URL]); + }); }); describe("insideStep", () => { @@ -259,7 +267,7 @@ describe("insideStep", () => { workflowRunCreatedAt: Date.now(), headers: new Headers(), steps: [], - url: "https://example.com/api/workflow", + url: URL, initialPayload: {}, }); const executor = (context as unknown as { executor?: Record }).executor; diff --git a/packages/mcp-toolkit/src/tasks/backends/workflow.ts b/packages/mcp-toolkit/src/tasks/backends/workflow.ts index 70f29d3..d875e9c 100644 --- a/packages/mcp-toolkit/src/tasks/backends/workflow.ts +++ b/packages/mcp-toolkit/src/tasks/backends/workflow.ts @@ -4,29 +4,32 @@ */ import type { Client as QStashClient, Receiver } from "@upstash/qstash"; import { Client as WorkflowClient, serve, type WorkflowContext } from "@upstash/workflow"; -import { env, lazy, requireEnv, resolveQStash, resolveReceiver } from "../../shared/clients.js"; +import { + INTERNAL_ERROR, + boundToUrl, + env, + lazy, + requireEnv, + resolveQStash, + resolveReceiver, +} from "../../shared/clients.js"; import { addQStashTelemetry } from "../../telemetry.js"; -import { type Task, type TaskDispatcher, type TaskEndpoints, type TaskJournal } from "../types.js"; - -/** JSON-RPC internal error. */ -const INTERNAL_ERROR = -32603; +import type { Task, TaskDispatcher, TaskEndpoints, TaskJournal } from "../types.js"; export type WorkflowDispatcherConfig = { /** The public URL of the route serving `tasks.createExecuteHandler()`, reachable from QStash. */ url: string; /** The Workflow client. Defaults to one from `QSTASH_TOKEN` (and `QSTASH_URL`). */ client?: WorkflowClient; - /** Extra headers to send when triggering a run. */ - headers?: Record; - /** Retries per step. Defaults to the Workflow SDK's default. */ - retries?: number; + /** The QStash client the endpoint schedules steps with. Defaults to one from `QSTASH_TOKEN`. */ + qstash?: QStashClient; /** * Verifies every request to the endpoint. Defaults to one from the `QSTASH_*_SIGNING_KEY` env * vars. Required either way: Workflow itself skips verification when they are missing. */ receiver?: Receiver; - /** The QStash client the endpoint schedules steps with. Defaults to one from `QSTASH_TOKEN`. */ - qstash?: QStashClient; + /** Retries per step. Defaults to the Workflow SDK's default. */ + retries?: number; /** Set `false` to skip this package's tag in the telemetry header. */ enableTelemetry?: boolean; }; @@ -40,8 +43,6 @@ type WorkflowPayload = { taskId?: string }; export class WorkflowDispatcher implements TaskDispatcher> { private readonly config: WorkflowDispatcherConfig; private readonly client: () => WorkflowClient; - private readonly handler: () => (request: Request) => Promise; - private endpoints: TaskEndpoints> | undefined; constructor(config: WorkflowDispatcherConfig) { this.config = config; @@ -55,36 +56,36 @@ export class WorkflowDispatcher implements TaskDispatcher this.buildHandler()); - } - - attach(endpoints: TaskEndpoints>): void { - this.endpoints = endpoints; } - async dispatch(task: Task): Promise { - const { workflowRunId } = await this.client().trigger({ + async dispatch(task: Task): Promise { + await this.client().trigger({ url: this.config.url, body: { taskId: task.taskId } satisfies WorkflowPayload, - headers: this.config.headers, retries: this.config.retries, // One run per task: Workflow refuses a run id that was already used. workflowRunId: task.taskId, }); - return workflowRunId; } - async cancel(dispatchId: string): Promise { - await this.client().cancel(dispatchId); + async cancel(taskId: string): Promise { + // The run id is the task id. + await this.client().cancel(taskId); } /** The workflow endpoint. Its `failureFunction` settles the task `failed` once retries run out. */ - createExecuteHandler(): (request: Request) => Promise { - return (request) => this.handler()(request); + createExecuteHandler( + endpoints: TaskEndpoints>, + ): (request: Request) => Promise { + // Built on the first request: route modules are evaluated at build time, without the keys. + const handler = lazy(() => this.buildHandler(endpoints)); + return (request) => handler()(request); } - private buildHandler(): (request: Request) => Promise { + private buildHandler( + endpoints: TaskEndpoints>, + ): (request: Request) => Promise { + const { url } = this.config; // Resolved here, so a missing key throws instead of serving unverified. const receiver = resolveReceiver("WorkflowDispatcher", this.config.receiver); const qstashClient = resolveQStash( @@ -95,33 +96,28 @@ export class WorkflowDispatcher implements TaskDispatcher( async (context) => { const taskId = context.requestPayload?.taskId; - if (taskId) await this.required().run(taskId, context, journalFor(context)); + if (taskId) await endpoints.run(taskId, context, journalFor(context)); }, { failureFunction: async ({ context, failStatus, failResponse }) => { const taskId = (context.requestPayload as WorkflowPayload | undefined)?.taskId; if (!taskId) return; - await this.required().fail(taskId, { + await endpoints.fail(taskId, { code: INTERNAL_ERROR, message: `Workflow run failed${failStatus ? ` (status ${failStatus})` : ""}`, data: { response: failResponse, workflowRunId: context.workflowRunId }, }); }, - receiver, + // Workflow verifies only body and signature; binding the URL refuses a signature that + // QStash issued for any other endpoint of the same account. + receiver: boundToUrl(receiver, url), qstashClient, // The public URL, as with QStash: behind a proxy `request.url` is the internal one. - url: this.config.url, + url, }, ); return handler; } - - private required(): TaskEndpoints> { - if (!this.endpoints) { - throw new Error("WorkflowDispatcher is not attached — pass it to createTaskLayer()."); - } - return this.endpoints; - } } /** diff --git a/packages/mcp-toolkit/src/tasks/core.test.ts b/packages/mcp-toolkit/src/tasks/core.test.ts index c9ab778..08052ef 100644 --- a/packages/mcp-toolkit/src/tasks/core.test.ts +++ b/packages/mcp-toolkit/src/tasks/core.test.ts @@ -6,10 +6,14 @@ import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; import { afterEach, describe, expect, it } from "vitest"; import * as z from "zod"; import { createTaskLayer } from "./core.js"; -import { InlineTaskDispatcher, MemoryTaskStore } from "./backends/memory.js"; import type { TaskContext, TaskLayer, TaskToolConfig, WireTask } from "./index.js"; -import { CountingDispatcher, ManualDispatcher, sleep, userIdOf } from "../test-support.js"; -import type { Task } from "./types.js"; +import { + InlineDispatcher, + ManualDispatcher, + MemoryTaskStore, + sleep, + userIdOf, +} from "../test-support.js"; const PROTOCOL_VERSION = "2026-07-28"; @@ -33,7 +37,7 @@ type Harness = { listTools: () => Promise; tasks: TaskLayer; store: MemoryTaskStore; - dispatcher: CountingDispatcher; + dispatcher: InlineDispatcher; close: () => Promise; }; @@ -49,9 +53,10 @@ async function harness( tool: Partial>> = {}, ): Promise { const store = new MemoryTaskStore(); - const dispatcher = - (layer.dispatcher as CountingDispatcher | undefined) ?? new CountingDispatcher(); + const dispatcher = (layer.dispatcher as InlineDispatcher | undefined) ?? new InlineDispatcher(); const tasks = createTaskLayer({ store, principal, ...layer, dispatcher }); + // Connects the dispatcher to the layer, as serving the execute route does. + tasks.createExecuteHandler(); // Defined once, the way module scope does it in an app. tasks.define( @@ -170,26 +175,29 @@ describe("createTaskLayer over MCP", () => { { description: "Generates a report.", inputSchema: z.object({ topic: z.string() }) }, async ({ topic }) => ({ content: [{ type: "text", text: topic }] }), ); + coldInstance.createExecuteHandler(); const now = new Date().toISOString(); + const taskId = crypto.randomUUID(); await store.create({ - taskId: "t1", + taskId, status: "working", createdAt: now, lastUpdatedAt: now, - ttlMs: null, + ttlMs: 60_000, + pollIntervalMs: 2_000, name: "generate_report", args: { topic: "ferries" }, owner: "alice", }); - await dispatcher.run("t1"); - expect((await store.get("t1"))?.status).toBe("completed"); + await dispatcher.run(taskId); + expect((await store.get(taskId))?.status).toBe("completed"); store.clear(); }); it("refuses to define the same task twice, or under a shared tool's name", () => { const tasks = createTaskLayer({ store: new MemoryTaskStore(), - dispatcher: new InlineTaskDispatcher(), + dispatcher: new InlineDispatcher(), principal, }); const config = { description: "d", inputSchema: z.object({}) }; @@ -258,11 +266,30 @@ describe("createTaskLayer over MCP", () => { it("answers an unknown task id with a tool error, not a protocol error", async () => { live = await harness(steppedHandler()); - const result = await live.status("nope"); + const result = await live.status(crypto.randomUUID()); expect(result.isError).toBe(true); expect(text(result)).toMatch(/Unknown task/); }); + it("refuses a task id that is not a UUID before reaching the store", async () => { + live = await harness(steppedHandler()); + let reads = 0; + const get = live.store.get.bind(live.store); + live.store.get = async (taskId) => { + reads += 1; + return get(taskId); + }; + for (const taskId of ["nope", "../other-prefix:key", ""]) { + const result = await live.status(taskId).catch((error: Error) => ({ + isError: true, + content: [{ type: "text", text: error.message }], + })); + expect(result.isError).toBe(true); + expect(text(result)).not.toMatch(/Unknown task/); + } + expect(reads).toBe(0); + }); + describe("cancellation", () => { it("flips the task to cancelled and stops the handler at its next check", async () => { live = await harness(steppedHandler(4, 60)); @@ -350,7 +377,7 @@ describe("createTaskLayer over MCP", () => { const start = await live.call("generate_report", { topic: "x" }, null); expect(start.isError).toBe(true); expect(text(start)).toMatch(/Not authenticated/); - expect(live.dispatcher.dispatched).toBe(0); + expect(live.dispatcher.dispatched).toHaveLength(0); const taskId = await live.start("x", "alice"); expect(text(await live.status(taskId, null))).toMatch(/Not authenticated/); @@ -359,7 +386,7 @@ describe("createTaskLayer over MCP", () => { }); it("refuses when principal throws, or returns no usable id at runtime", async () => { - for (const answer of [undefined, "", null, { id: "" }, 42]) { + for (const answer of [undefined, "", null, { id: "alice" }, 42]) { live = await harness(steppedHandler(1, 1), { // Plain JavaScript, or an `as string` that lied: the layer still fails closed. principal: (() => answer) as never, @@ -375,7 +402,7 @@ describe("createTaskLayer over MCP", () => { expect(text(await live.call("generate_report", { topic: "x" }, "alice"))).toMatch( /Not authenticated/, ); - expect(live.dispatcher.dispatched).toBe(0); + expect(live.dispatcher.dispatched).toHaveLength(0); }); it("never answers for a stored task without an owner", async () => { @@ -385,7 +412,7 @@ describe("createTaskLayer over MCP", () => { // A record written by something other than the layer, with no owner on it. const record = await live.store.get(taskId); live.store.clear(); - await live.store.create({ ...record!, owner: undefined as unknown as string }); + live.store.seed({ ...record!, owner: undefined as unknown as string }); expect(text(await live.status(taskId, "alice"))).toMatch(/Unknown task/); }); @@ -393,7 +420,7 @@ describe("createTaskLayer over MCP", () => { expect(() => createTaskLayer({ store: new MemoryTaskStore(), - dispatcher: new InlineTaskDispatcher(), + dispatcher: new InlineDispatcher(), } as unknown as Parameters[0]), ).toThrow(/principal/); }); @@ -468,55 +495,42 @@ describe("createTaskLayer over MCP", () => { it("fails the task when it cannot be dispatched, instead of leaving it working", async () => { const manual = new ManualDispatcher(); manual.failNext = new Error("queue unavailable"); - const settled: Task[] = []; - live = await harness(steppedHandler(1, 1), { - dispatcher: manual as never, - onSettle: (task) => { - settled.push(task); - }, - }); + let created: string | undefined; + live = await harness(steppedHandler(1, 1), { dispatcher: manual as never }); + const create = live.store.create.bind(live.store); + live.store.create = async (task) => { + created = task.taskId; + await create(task); + }; const result = await live.call("generate_report", { topic: "x" }); expect(result.isError).toBe(true); - expect(settled).toHaveLength(1); - expect(settled[0]).toMatchObject({ status: "failed", statusMessage: "Could not be queued" }); + expect(await live.store.get(created!)).toMatchObject({ + status: "failed", + statusMessage: "Could not be queued", + }); }); }); - it("calls onSettle once and cancels the dispatch once, however often a task is cancelled", async () => { + it("stops pending deliveries when a task is cancelled, and only then", async () => { const manual = new ManualDispatcher(); - const settled: Task[] = []; - live = await harness(steppedHandler(1, 1), { - dispatcher: manual as never, - onSettle: (task) => { - settled.push(task); - }, - }); - const taskId = await live.start("x"); - await live.cancel(taskId); - await live.cancel(taskId); - expect(settled.map((t) => t.status)).toEqual(["cancelled"]); - expect(manual.cancelled).toEqual(["msg_1"]); - }); + live = await harness(steppedHandler(1, 1), { dispatcher: manual as never }); + const finished = await live.start("x"); + await manual.run(finished); + await live.cancel(finished); // already completed: nothing to stop + expect(manual.cancelled).toEqual([]); - it("keeps ttlMs: null (unlimited) instead of falling back to the default", async () => { - live = await harness(steppedHandler(1, 1), {}, { ttlMs: null }); - const result = await live.call("generate_report", { topic: "x" }); - expect(result.structuredContent?.ttlMs).toBeNull(); - await live.dispatcher.drain(); + const taskId = await live.start("y"); + await live.cancel(taskId); + expect(manual.cancelled).toContain(taskId); }); - it("registers the shared tools under custom names when asked", async () => { + it("uses the layer's default ttl and poll interval", async () => { live = await harness(steppedHandler(1, 1), { - toolNames: { status: "upstash_task_status", cancel: "upstash_task_cancel" }, + defaults: { ttlMs: 60_000, pollIntervalMs: 5_000 }, }); - expect(await live.listTools()).toEqual( - expect.arrayContaining(["upstash_task_status", "upstash_task_cancel"]), - ); - const taskId = await live.start("x"); + const result = await live.call("generate_report", { topic: "x" }); + expect(result.structuredContent).toMatchObject({ ttlMs: 60_000, pollIntervalMs: 5_000 }); await live.dispatcher.drain(); - expect((await live.call("upstash_task_status", { taskId })).structuredContent?.status).toBe( - "completed", - ); }); it("infers handler argument types from the input schema", async () => { @@ -538,8 +552,9 @@ describe("wire shape", () => { status: "working", createdAt: new Date().toISOString(), lastUpdatedAt: new Date().toISOString(), - ttlMs: null, + ttlMs: 300_000, + pollIntervalMs: 2_000, }; - expect(wire.ttlMs).toBeNull(); + expect(wire.ttlMs).toBe(300_000); }); }); diff --git a/packages/mcp-toolkit/src/tasks/core.ts b/packages/mcp-toolkit/src/tasks/core.ts index 0717ce6..294d48f 100644 --- a/packages/mcp-toolkit/src/tasks/core.ts +++ b/packages/mcp-toolkit/src/tasks/core.ts @@ -6,58 +6,43 @@ import type { McpServer, StandardSchemaWithJSON } from "@modelcontextprotocol/server"; import * as z from "zod"; import { - callerOf as callerFrom, - type Caller, + callerOf, requirePrincipal, resolvePrincipal, - type CallerAuth, type PrincipalResolver, } from "../shared/auth.js"; -import { - isTerminal, - UnknownTaskError, - type Task, - type TaskContext, - type TaskDispatcher, - type TaskError, - type TaskJournal, - type TaskStore, - type WireTask, +import { INTERNAL_ERROR } from "../shared/env.js"; +import type { + Task, + TaskContext, + TaskDispatcher, + TaskError, + TaskJournal, + TaskStore, + WireTask, } from "./types.js"; const DEFAULT_TTL_MS = 300_000; const DEFAULT_POLL_INTERVAL_MS = 2_000; -/** JSON-RPC internal error. */ -const INTERNAL_ERROR = -32603; - -/** The names the two shared tools are registered under, unless overridden. */ -export const DEFAULT_TOOL_NAMES = { status: "task_status", cancel: "task_cancel" } as const; - -export type { Caller, CallerAuth }; +const STATUS_TOOL = "task_status"; +const CANCEL_TOOL = "task_cancel"; export type TaskLayerOptions = { store: TaskStore; dispatcher: TaskDispatcher; - defaults?: { - /** Retention window from creation. `null` means unlimited. Defaults to 5 minutes. */ - ttlMs?: number | null; - /** Poll interval suggested to the model. Defaults to 2s. */ - pollIntervalMs?: number; - }; /** * Who is calling, usually your user id from `auth`. Required, and it must return an id: throw - * when it can't, and the call is refused as not authenticated. Every task is owned by its caller. `auth.clientId` is the OAuth app - * (shared by every ChatGPT user), so it is the wrong key. A server with no users of its own - * passes `() => "local"`. + * when it can't, and the call is refused as not authenticated. Every task is owned by its + * caller. `auth.clientId` is the OAuth app (shared by every ChatGPT user), so it is the wrong + * key. A server with no users of its own passes `() => "local"`. */ principal: PrincipalResolver; - /** Renames the shared tools, e.g. to namespace them. */ - toolNames?: { status?: string; cancel?: string }; - /** - * Called once per task, by the write that made it terminal. Throws are logged and swallowed. - * `taskFinishedEvent(...).onSettle` from `@upstash/mcp-toolkit/events` plugs in here. - */ - onSettle?: (task: Task) => void | Promise; + defaults?: { + /** Retention window from creation. Defaults to 5 minutes. */ + ttlMs?: number; + /** Poll interval suggested to the model. Defaults to 2s. */ + pollIntervalMs?: number; + }; }; export type TaskToolConfig = { @@ -66,11 +51,6 @@ export type TaskToolConfig = { description: string; /** A Standard Schema (Zod 4, ArkType, Valibot) for the arguments. */ inputSchema: Schema; - /** Retention window for this tool's tasks. `null` means unlimited. */ - ttlMs?: number | null; - pollIntervalMs?: number; - /** Status message at creation. Defaults to `"Queued for durable execution"`. */ - queuedMessage?: string; /** Status message on success. Defaults to `"Completed"`. */ completedMessage?: string; }; @@ -90,23 +70,17 @@ export type TaskHandler = ( task: TaskContext & TContext, ) => Promise>; -export type TaskDefinition = { readonly name: string }; - export type TaskLayer = { /** Declares a task tool and its handler. Call it at module scope, so every instance has it. */ define( name: string, config: TaskToolConfig, handler: TaskHandler, TContext>, - ): TaskDefinition; + ): void; /** Adds every defined task tool, plus `task_status` and `task_cancel`, to a server. */ register(server: McpServer): void; /** The dispatcher's delivery endpoint: `export const POST = tasks.createExecuteHandler()`. */ createExecuteHandler(): (request: Request) => Promise; - /** Reads a task server-side, without an ownership check. */ - getTask(taskId: string): Promise; - /** Cancels a task server-side, without an ownership check. Idempotent. */ - cancelTask(taskId: string): Promise; }; /** @@ -126,128 +100,54 @@ export type TaskLayer = { export function createTaskLayer( options: TaskLayerOptions, ): TaskLayer { - const { store, dispatcher, defaults = {}, onSettle } = options; + const { store, dispatcher } = options; const principal = requirePrincipal(options.principal, "createTaskLayer"); - const toolNames = { - status: options.toolNames?.status ?? DEFAULT_TOOL_NAMES.status, - cancel: options.toolNames?.cancel ?? DEFAULT_TOOL_NAMES.cancel, - }; + const ttlMs = options.defaults?.ttlMs ?? DEFAULT_TTL_MS; + const pollIntervalMs = options.defaults?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; // Keyed by tool name: a delivery only carries a task id, and the record names its tool. const definitions = new Map< string, - { config: TaskToolConfig; handler: TaskHandler } + { config: TaskToolConfig; handler: TaskHandler } >(); - const wired = new WeakSet(); - - const callerOf = async (context: unknown) => - (await resolvePrincipal(principal, callerFrom(context)))?.id; - - async function runSettleHook(task: Task): Promise { - if (!onSettle) return; - try { - await onSettle(task); - } catch (error) { - console.warn(`[mcp-toolkit] onSettle failed for task ${task.taskId}:`, error); - } - } - async function settle(taskId: string, patch: Parameters[1]) { - const outcome = await store.settle(taskId, patch); - if (outcome?.settled) await runSettleHook(outcome.task); - return outcome; - } + const callerId = (context: unknown) => resolvePrincipal(principal, callerOf(context)); function define( name: string, config: TaskToolConfig, handler: TaskHandler, TContext>, - ): TaskDefinition { - if (name === toolNames.status || name === toolNames.cancel) { + ): void { + if (name === STATUS_TOOL || name === CANCEL_TOOL) { throw new Error(`"${name}" is reserved for the shared task tools`); } if (definitions.has(name)) throw new Error(`Task "${name}" is already defined`); definitions.set(name, { config: config as unknown as TaskToolConfig, - handler: handler as TaskHandler, + handler: handler as TaskHandler, }); - return { name }; } function register(server: McpServer): void { - if (wired.has(server)) return; - wired.add(server); - for (const [name, { config }] of definitions) registerTaskTool(server, name, config); - registerSharedTools(server); - } - - function registerTaskTool( - server: McpServer, - name: string, - config: TaskToolConfig, - ): void { - const callback = async (args: unknown, context: unknown): Promise> => { - const owner = await callerOf(context); - if (!owner) return notAuthenticatedResult(); - const taskId = crypto.randomUUID(); - const now = new Date().toISOString(); - const task: Task = { - taskId, - status: "working", - statusMessage: config.queuedMessage ?? "Queued for durable execution", - createdAt: now, - lastUpdatedAt: now, - // `null` means unlimited, so `??` would wrongly replace it. - ttlMs: - config.ttlMs !== undefined - ? config.ttlMs - : defaults.ttlMs !== undefined - ? defaults.ttlMs - : DEFAULT_TTL_MS, - pollIntervalMs: - config.pollIntervalMs ?? defaults.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, + for (const [name, { config }] of definitions) { + server.registerTool( name, - args, - owner, - }; - - // The record is durable before the id goes out, and before the work is queued. - if (await store.create(task)) throw new Error(`Task id ${taskId} is already taken`); - let dispatchId: string | undefined; - try { - dispatchId = await dispatcher.dispatch(task); - } catch (error) { - // Nothing will ever run this record: fail it rather than leave it `working` until its TTL. - await settle(taskId, { - status: "failed", - statusMessage: "Could not be queued", - error: { code: INTERNAL_ERROR, message: "The task could not be dispatched" }, - }).catch(() => undefined); - throw error; - } - const saved = dispatchId ? await store.update(taskId, { dispatchId }) : task; - return startedResult(saved, toolNames); - }; - - server.registerTool( - name, - { - title: config.title, - description: - `${config.description.trim()} Runs in the background: returns a taskId immediately. ` + - `Call ${toolNames.status} with it to get progress and, once completed, the result.`, - inputSchema: config.inputSchema, - }, - callback as never, - ); - } + { + title: config.title, + description: + `${config.description.trim()} Runs in the background: returns a taskId immediately. ` + + `Call ${STATUS_TOOL} with it to get progress and, once completed, the result.`, + inputSchema: config.inputSchema, + }, + (async (args: unknown, context: unknown) => startTask(name, args, context)) as never, + ); + } - function registerSharedTools(server: McpServer): void { const inputSchema = z.object({ - taskId: z.string().describe("The taskId returned when the task was started."), + taskId: z.uuid().describe("The taskId returned when the task was started."), }); server.registerTool( - toolNames.status, + STATUS_TOOL, { title: "Task status", description: @@ -258,15 +158,15 @@ export function createTaskLayer( annotations: { readOnlyHint: true, idempotentHint: true }, }, (async ({ taskId }: { taskId: string }, context: unknown) => { - const caller = await callerOf(context); - if (!caller) return notAuthenticatedResult(); + const caller = await callerId(context); + if (!caller) return errorResult(NOT_AUTHENTICATED); const task = await owned(taskId, caller); - return task ? statusResult(task) : unknownTaskResult(taskId); + return task ? statusResult(task) : errorResult(unknownTask(taskId)); }) as never, ); server.registerTool( - toolNames.cancel, + CANCEL_TOOL, { title: "Cancel task", description: @@ -276,37 +176,74 @@ export function createTaskLayer( annotations: { destructiveHint: true, idempotentHint: true }, }, (async ({ taskId }: { taskId: string }, context: unknown) => { - const caller = await callerOf(context); - if (!caller) return notAuthenticatedResult(); - if (!(await owned(taskId, caller))) return unknownTaskResult(taskId); - const task = await cancelTask(taskId); - return task ? statusResult(task) : unknownTaskResult(taskId); + const caller = await callerId(context); + if (!caller) return errorResult(NOT_AUTHENTICATED); + if (!(await owned(taskId, caller))) return errorResult(unknownTask(taskId)); + const task = await store.settle(taskId, { + status: "cancelled", + statusMessage: "Cancelled by client", + }); + if (!task) return errorResult(unknownTask(taskId)); + // Also stop pending redeliveries. A delivery already running can't be recalled, which is + // why handlers check `isCancelled()`. + if (task.status === "cancelled") await dispatcher.cancel(taskId).catch(() => undefined); + return statusResult(task); }) as never, ); } - async function cancelTask(taskId: string): Promise { - const outcome = await settle(taskId, { - status: "cancelled", - statusMessage: "Cancelled by client", - }); - if (!outcome) return null; - // Also stop pending redeliveries. A delivery already running can't be recalled, which is why - // handlers check `isCancelled()`. - if (outcome.settled && outcome.task.dispatchId) { - await dispatcher.cancel(outcome.task.dispatchId).catch(() => undefined); + async function startTask(name: string, args: unknown, context: unknown) { + const owner = await callerId(context); + if (!owner) return errorResult(NOT_AUTHENTICATED); + const now = new Date().toISOString(); + const task: Task = { + taskId: crypto.randomUUID(), + status: "working", + statusMessage: "Queued for durable execution", + createdAt: now, + lastUpdatedAt: now, + ttlMs, + pollIntervalMs, + name, + args, + owner, + }; + // The record is durable before the id goes out, and before the work is queued. + await store.create(task); + try { + await dispatcher.dispatch(task); + } catch (error) { + // Nothing will ever run this record: fail it rather than leave it `working` until its TTL. + await store + .settle(task.taskId, { + status: "failed", + statusMessage: "Could not be queued", + error: { code: INTERNAL_ERROR, message: "The task could not be dispatched" }, + }) + .catch(() => undefined); + throw error; } - return outcome.task; + return { + content: [ + { + type: "text", + text: + `Started task ${task.taskId}. Call ${STATUS_TOOL} with taskId "${task.taskId}" ` + + `in about ${seconds(pollIntervalMs)} to check on it.`, + }, + ], + structuredContent: toWire(task), + }; } /** * Runs a delivered task. A throw propagates and leaves the task `working`: only the transport * knows whether it will deliver again, so only it records the final failure. */ - async function executeTask(taskId: string, context?: TContext, journal?: TaskJournal) { - const task = await read(taskId); + async function run(taskId: string, context: TContext, journal?: TaskJournal): Promise { + const task = await store.get(taskId); // Expired or unknown, or a redelivery of a finished task: nothing to do. - if (!task || isTerminal(task.status)) return; + if (!task || task.status !== "working") return; const definition = definitions.get(task.name); if (!definition) { throw new Error( @@ -319,118 +256,62 @@ export function createTaskLayer( const taskContext: TaskContext = { taskId, update: async (statusMessage) => { - const write = () => store.update(taskId, { statusMessage }).then(() => undefined); + const write = () => store.update(taskId, { statusMessage }); await (journal ? journal(`mcp-task:update:${++writes}`, write) : write()); }, - isCancelled: async () => { - const current = await store.get(taskId); - return current === null || current.status === "cancelled"; - }, + isCancelled: async () => (await store.get(taskId))?.status !== "working", }; - const result = await (definition.handler as TaskHandler)( - task.args, - mergeContext(taskContext, context), - ); + const result = await definition.handler(task.args, mergeContext(taskContext, context)); // A cancel that landed meanwhile wins: settle refuses the second terminal write. - await settle(taskId, { + await store.settle(taskId, { status: "completed", statusMessage: definition.config.completedMessage ?? "Completed", result, }); } - async function failTask(taskId: string, error: TaskError) { - await settle(taskId, { status: "failed", statusMessage: "Execution failed", error }); - } - - /** Reads a task, folding a store's {@link UnknownTaskError} into null. */ - async function read(taskId: string): Promise { - try { - return await store.get(taskId); - } catch (cause) { - if (cause instanceof UnknownTaskError) return null; - throw cause; - } + async function fail(taskId: string, error: TaskError): Promise { + await store.settle(taskId, { status: "failed", statusMessage: "Execution failed", error }); } /** Another caller's task reads as unknown, so ids cannot be probed. */ async function owned(taskId: string, caller: string): Promise { - const task = await read(taskId); + const task = await store.get(taskId); return task && task.owner === caller ? task : null; } - function createExecuteHandler(): (request: Request) => Promise { - if (!dispatcher.createExecuteHandler) { - throw new Error( - "This dispatcher runs tasks in-process and has no delivery endpoint. Use QStashDispatcher or WorkflowDispatcher.", - ); - } - return dispatcher.createExecuteHandler(); - } + return { + define, + register, + createExecuteHandler: () => dispatcher.createExecuteHandler({ run, fail }), + }; +} - dispatcher.attach?.({ run: executeTask, fail: failTask }); +const NOT_AUTHENTICATED = "Not authenticated: this server could not identify the caller."; - return { define, register, createExecuteHandler, getTask: read, cancelTask }; -} +const unknownTask = (taskId: string) => + `Unknown task: ${taskId}. It may have expired, or the id may be wrong.`; -function startedResult(task: Task, toolNames: { status: string }): Record { - const lead = `Started task ${task.taskId}.`; - return { - content: [ - { - type: "text", - text: - `${lead} Call ${toolNames.status} with taskId "${task.taskId}" ` + - `in about ${seconds(task.pollIntervalMs)} to check on it.`, - }, - ], - structuredContent: toWire(task), - }; +function errorResult(text: string): Record { + return { isError: true, content: [{ type: "text", text }] }; } /** A status line, followed by the task's own result content once it completed. */ function statusResult(task: Task): Record { - const wire = toWire(task); const line = `Task ${task.taskId} is ${task.status}${task.statusMessage ? `: ${task.statusMessage}` : "."}`; - if (task.status === "completed") { - const content = Array.isArray(task.result?.content) ? task.result.content : []; - return { content: [{ type: "text", text: line }, ...content], structuredContent: wire }; - } - if (task.status === "failed") { - return { - content: [{ type: "text", text: `${line} Error: ${task.error?.message ?? "unknown"}` }], - structuredContent: wire, - }; - } - const hint = - task.status === "working" ? ` Check again in about ${seconds(task.pollIntervalMs)}.` : ""; - return { content: [{ type: "text", text: line + hint }], structuredContent: wire }; -} - -function notAuthenticatedResult(): Record { - return { - isError: true, - content: [ - { type: "text", text: "Not authenticated: this server could not identify the caller." }, - ], - }; -} - -function unknownTaskResult(taskId: string): Record { - return { - isError: true, - content: [ - { - type: "text", - text: `Unknown task: ${taskId}. It may have expired, or the id may be wrong.`, - }, - ], - }; + const extra = + task.status === "completed" && Array.isArray(task.result?.content) ? task.result.content : []; + const text = + task.status === "failed" + ? `${line} Error: ${task.error?.message ?? "unknown"}` + : task.status === "working" + ? `${line} Check again in about ${seconds(task.pollIntervalMs)}.` + : line; + return { content: [{ type: "text", text }, ...extra], structuredContent: toWire(task) }; } -const seconds = (ms: number | undefined) => - `${Math.max(1, Math.round((ms ?? DEFAULT_POLL_INTERVAL_MS) / 1000))}s`; +const seconds = (ms: number) => `${Math.max(1, Math.round(ms / 1000))}s`; /** * Merges the task context into the transport's, as one object. Assigned onto the instance rather @@ -445,7 +326,7 @@ function mergeContext( } /** Strips the server-only fields, leaving what the model sees in `structuredContent`. */ -export function toWire(task: Task): WireTask { - const { name: _name, args: _args, dispatchId: _dispatchId, owner: _owner, ...wire } = task; +function toWire(task: Task): WireTask { + const { name: _name, args: _args, owner: _owner, ...wire } = task; return wire; } diff --git a/packages/mcp-toolkit/src/tasks/index.ts b/packages/mcp-toolkit/src/tasks/index.ts index 4f7e89d..28fffb8 100644 --- a/packages/mcp-toolkit/src/tasks/index.ts +++ b/packages/mcp-toolkit/src/tasks/index.ts @@ -4,39 +4,24 @@ */ export { createTaskLayer, - toWire, - DEFAULT_TOOL_NAMES, - type Caller, - type CallerAuth, - type TaskDefinition, type TaskHandler, type TaskLayer, type TaskLayerOptions, type TaskToolConfig, } from "./core.js"; -export type { Principal, PrincipalResolver } from "../shared/auth.js"; +export type { Caller, PrincipalResolver } from "../shared/auth.js"; -export { - isTerminal, - TERMINAL_STATUSES, - UnknownTaskError, - type SettleResult, - type Task, - type TaskContext, - type TaskDispatcher, - type TaskEndpoints, - type TaskError, - type TaskJournal, - type TaskPatch, - type TaskStatus, - type TaskStore, - type TerminalTaskPatch, - type TerminalTaskStatus, - type WireTask, +export type { + Task, + TaskContext, + TaskDispatcher, + TaskEndpoints, + TaskError, + TaskJournal, + TaskPatch, + TaskStatus, + TaskStore, + TerminalTaskStatus, + 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-toolkit/src/tasks/types.ts b/packages/mcp-toolkit/src/tasks/types.ts index 860d942..5dca768 100644 --- a/packages/mcp-toolkit/src/tasks/types.ts +++ b/packages/mcp-toolkit/src/tasks/types.ts @@ -4,26 +4,9 @@ */ /** A task's states, from the `io.modelcontextprotocol/tasks` extension. The last three are terminal. */ -export type TaskStatus = "working" | "input_required" | "completed" | "failed" | "cancelled"; +export type TaskStatus = "working" | "completed" | "failed" | "cancelled"; -export type TerminalTaskStatus = Extract; - -export const TERMINAL_STATUSES: ReadonlySet = new Set([ - "completed", - "failed", - "cancelled", -]); - -export const isTerminal = (status: TaskStatus): status is TerminalTaskStatus => - TERMINAL_STATUSES.has(status); - -/** Thrown by a store for an unknown or expired task. Not an MCP error, so stores need no SDK. */ -export class UnknownTaskError extends Error { - override readonly name = "UnknownTaskError"; - constructor(readonly taskId: string) { - super(`Unknown task: ${taskId}`); - } -} +export type TerminalTaskStatus = Exclude; /** A JSON-RPC error, as carried by a `failed` task. */ export type TaskError = { @@ -41,9 +24,9 @@ export type WireTask = { createdAt: string; /** ISO-8601. */ lastUpdatedAt: string; - /** Retention window in milliseconds. `null` means unlimited. */ - ttlMs: number | null; - pollIntervalMs?: number; + /** Retention window in milliseconds, from creation. */ + ttlMs: number; + pollIntervalMs: number; /** The tool result, once `completed`. */ result?: Record; /** Once `failed`. */ @@ -56,45 +39,29 @@ export type Task = WireTask & { name: string; /** The validated tool input. */ args: unknown; - /** The dispatcher's handle, so cancel can stop pending deliveries. */ - dispatchId?: string; /** The caller that started the task. Only they can read or cancel it. */ owner: string; }; -export type TaskPatch = Partial>; - -export type TerminalTaskPatch = TaskPatch & { status: TerminalTaskStatus }; - -/** What {@link TaskStore.settle} returns for an existing task. */ -export type SettleResult = { - /** The task after the call: settled by it, or unchanged because it was already terminal. */ - task: Task; - /** True only for the call that performed the terminal transition. */ - settled: boolean; -}; +/** The fields a write may change. */ +export type TaskPatch = Partial>; export interface TaskStore { - /** - * Durably creates a task unless one with that id exists, atomically. Resolves `null` when it - * created the task, or the existing task otherwise. - */ - create(task: Task): Promise; + /** Durably creates a task, with its TTL. Task ids are random UUIDs. */ + create(task: Task): Promise; /** The latest state of a task, or `null` when it is absent or expired. */ get(taskId: string): Promise; - /** - * Applies a non-terminal patch without extending the TTL. A terminal task is returned - * unchanged. Throws {@link UnknownTaskError} when the task is missing. - */ - update(taskId: string, patch: TaskPatch): Promise; + /** Applies a non-terminal patch without extending the TTL. A no-op on a terminal or missing task. */ + update(taskId: string, patch: TaskPatch): Promise; /** - * Atomically moves a non-terminal task to a terminal state. First terminal write wins, so a late - * `completed` cannot overwrite a `cancelled`. Resolves `null` when the task is missing. + * Atomically moves a non-terminal task to a terminal state, and returns the task after the call. + * First terminal write wins, so a late `completed` cannot overwrite a `cancelled`. Resolves + * `null` when the task is missing. */ - settle(taskId: string, patch: TerminalTaskPatch): Promise; + settle(taskId: string, patch: TaskPatch & { status: TerminalTaskStatus }): Promise; } /** @@ -102,28 +69,22 @@ export interface TaskStore { * nothing for a queue, the `WorkflowContext` for Upstash Workflow. */ export interface TaskDispatcher { - /** - * Durably accepts a delivery and returns a handle for {@link cancel}. Must be idempotent per - * task id, which is a random UUID. - */ - dispatch(task: Task): Promise; + /** Durably accepts a delivery. Must be idempotent per task id. */ + dispatch(task: Task): Promise; /** Stops pending deliveries, when the transport can. Idempotent. */ - cancel(dispatchId: string): Promise; - - /** Receives the layer's entry points when passed to `createTaskLayer`. */ - attach?(endpoints: TaskEndpoints): void; + cancel(taskId: string): Promise; - /** The transport's delivery endpoint, for dispatchers that deliver over HTTP. */ - createExecuteHandler?(): (request: Request) => Promise; + /** The transport's delivery endpoint, which hands each delivery to the layer's entry points. */ + createExecuteHandler(endpoints: TaskEndpoints): (request: Request) => Promise; } -/** The layer's entry points, handed to a dispatcher by {@link TaskDispatcher.attach}. */ +/** The layer's entry points, handed to a dispatcher's execute handler. */ export type TaskEndpoints = { /** Runs a delivered task. Rejects if the handler threw, which means "deliver again". */ - run(taskId: string, context: TContext, journal?: TaskJournal): Promise; + run(taskId: string, context: TContext, journal?: TaskJournal): Promise; /** Records a terminal failure, once the transport has stopped retrying. */ - fail(taskId: string, error: TaskError): Promise; + fail(taskId: string, error: TaskError): Promise; }; /** How a replaying transport runs a side effect once. The core wraps `task.update` with it. */ diff --git a/packages/mcp-toolkit/src/test-support.ts b/packages/mcp-toolkit/src/test-support.ts index af34522..e9770fc 100644 --- a/packages/mcp-toolkit/src/test-support.ts +++ b/packages/mcp-toolkit/src/test-support.ts @@ -1,15 +1,31 @@ /** - * Test-only helpers (never imported by `index.ts`). + * Test-only helpers and in-memory backends (never imported by an entry point). * - * 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. + * Per the project's testing policy the Redis backends are 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 { createHash, createHmac, randomUUID } from "node:crypto"; import { config } from "dotenv"; +import { Receiver } from "@upstash/qstash"; import { Redis } from "@upstash/redis"; -import { InlineTaskDispatcher } from "./tasks/backends/memory.js"; -import type { Task, TaskDispatcher, TaskEndpoints, TaskError, TaskJournal } from "./tasks/types.js"; +import type { + DeliveryJob, + EventDelivery, + SendJob, + Subscription, + SubscriptionStore, +} from "./events/types.js"; +import type { + Task, + TaskDispatcher, + TaskEndpoints, + TaskError, + TaskJournal, + TaskPatch, + TaskStore, + TerminalTaskStatus, +} from "./tasks/types.js"; // Load repo-root .env (no-op if already loaded or absent). config(); @@ -42,63 +58,249 @@ export async function cleanupKeys(redis: Redis, prefix: string): Promise { export const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); -/** An inline dispatcher that counts what it was handed. */ -export class CountingDispatcher extends InlineTaskDispatcher { - dispatched = 0; - override async dispatch(task: Task): Promise { - this.dispatched += 1; - return await super.dispatch(task); +/** The principal every test uses: the user id the verified auth carries, or a refusal. */ +export function userIdOf({ auth }: { auth?: { extra?: Record } }): string { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; +} + +/** A valid events `secretKey`: base64 of 32 bytes. */ +export const TEST_SECRET_KEY = Buffer.alloc(32, 7).toString("base64"); + +// --------------------------------------------------------------------------------------------- +// QStash signatures + +/** The signing key {@link testReceiver} accepts. */ +export const TEST_SIGNING_KEY = "sig_test_current"; + +/** A real QStash `Receiver`, with keys only the tests know. */ +export function testReceiver(): Receiver { + return new Receiver({ currentSigningKey: TEST_SIGNING_KEY, nextSigningKey: "sig_test_next" }); +} + +/** + * The `Upstash-Signature` QStash would send: an HS256 JWT over the body hash, issued for `url`. + * Override any claim to forge a bad one. + */ +export function signQStash( + body: string, + url: string, + overrides: { key?: string; sub?: string; body?: string; exp?: number } = {}, +): string { + const now = Math.floor(Date.now() / 1000); + const b64url = (data: string | Buffer) => Buffer.from(data).toString("base64url"); + const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const claims = b64url( + JSON.stringify({ + iss: "Upstash", + sub: overrides.sub ?? url, + exp: overrides.exp ?? now + 300, + nbf: now - 1, + iat: now, + jti: randomUUID(), + body: overrides.body ?? createHash("sha256").update(body).digest("base64url"), + }), + ); + const signature = createHmac("sha256", overrides.key ?? TEST_SIGNING_KEY) + .update(`${header}.${claims}`) + .digest("base64url"); + return `${header}.${claims}.${signature}`; +} + +/** A POST QStash would make to `url`, signed for `signedFor` (defaults to `url`). */ +export function qstashRequest( + url: string, + body: unknown, + signature?: Parameters[2], +): Request { + const text = typeof body === "string" ? body : JSON.stringify(body); + return new Request(url, { + method: "POST", + headers: { + "content-type": "application/json", + "upstash-signature": signQStash(text, url, signature), + }, + body: text, + }); +} + +// --------------------------------------------------------------------------------------------- +// In-memory task backends + +const isTerminal = (status: string): status is TerminalTaskStatus => status !== "working"; + +/** 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 }); + // Stands in for Redis' expiry. Unref'd so it 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 { + this.write(taskId, patch); + } + + async settle(taskId: string, patch: TaskPatch & { status: TerminalTaskStatus }) { + return this.write(taskId, patch); + } + + /** Puts a record in as-is, e.g. one written by an older version. */ + seed(task: Task): void { + this.tasks.set(task.taskId, { ...task }); + } + + /** Drops every task and its pending expiry. */ + clear(): void { + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + this.tasks.clear(); + } + + /** Synchronous, so nothing interleaves between the check and the write. */ + private write(taskId: string, patch: TaskPatch): Task | null { + const task = this.tasks.get(taskId); + if (!task) return null; + if (isTerminal(task.status)) return { ...task }; + const next: Task = { ...task, ...patch, lastUpdatedAt: new Date().toISOString() }; + this.tasks.set(taskId, next); + return { ...next }; } } /** * A dispatcher that records dispatches and runs nothing on its own, so a test drives each - * delivery by hand, the way a queue would. + * delivery by hand, the way a queue would. It is connected when the layer's + * `createExecuteHandler()` is called. */ export class ManualDispatcher implements TaskDispatcher { readonly dispatched: Task[] = []; readonly cancelled: string[] = []; /** Makes the next `dispatch` throw this error. */ failNext: Error | undefined; - private endpoints: TaskEndpoints | undefined; + protected endpoints: TaskEndpoints | undefined; - attach(endpoints: TaskEndpoints): void { - this.endpoints = endpoints; - } - - async dispatch(task: Task): Promise { + async dispatch(task: Task): Promise { if (this.failNext) { const error = this.failNext; this.failNext = undefined; throw error; } this.dispatched.push(task); - return `msg_${this.dispatched.length}`; } - async cancel(dispatchId: string): Promise { - this.cancelled.push(dispatchId); + async cancel(taskId: string): Promise { + this.cancelled.push(taskId); + } + + createExecuteHandler(endpoints: TaskEndpoints): () => Promise { + this.endpoints = endpoints; + return async () => new Response("manual dispatcher: drive deliveries with run()"); } /** Delivers a task, as the transport would. */ - run(taskId: string, context?: TContext, journal?: TaskJournal): Promise { - return this.attached().run(taskId, context as TContext, journal); + run(taskId: string, context?: TContext, journal?: TaskJournal): Promise { + return this.connected().run(taskId, context as TContext, journal); } /** Reports that the transport gave up, as a failure callback would. */ - fail(taskId: string, error: TaskError): Promise { - return this.attached().fail(taskId, error); + fail(taskId: string, error: TaskError): Promise { + return this.connected().fail(taskId, error); } - private attached(): TaskEndpoints { - if (!this.endpoints) throw new Error("ManualDispatcher is not attached"); + protected connected(): TaskEndpoints { + if (!this.endpoints) throw new Error("Call tasks.createExecuteHandler() first"); return this.endpoints; } } -/** The principal every test uses: the user id the verified auth carries, or a refusal. */ -export function userIdOf({ auth }: { auth?: { extra?: Record } }): string { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return userId; +/** + * Runs each task in the current process, on the next microtask, and counts dispatches. A throw + * fails the task: there are no retries. + */ +export class InlineDispatcher extends ManualDispatcher { + private readonly pending = new Set>(); + + override async dispatch(task: Task): Promise { + await super.dispatch(task); + const endpoints = this.connected(); + const run = Promise.resolve() + .then(() => endpoints.run(task.taskId, undefined)) + .catch((cause: unknown) => + endpoints + .fail(task.taskId, { + code: -32603, + message: cause instanceof Error ? cause.message : String(cause), + }) + .catch(() => undefined), + ); + this.pending.add(run); + void run.finally(() => this.pending.delete(run)); + } + + /** Resolves once every dispatched task has settled. */ + async drain(): Promise { + while (this.pending.size > 0) await Promise.all([...this.pending]); + } +} + +// --------------------------------------------------------------------------------------------- +// In-memory event backends + +/** An in-process {@link SubscriptionStore}. Not durable, not shared between instances. */ +export class MemorySubscriptionStore implements SubscriptionStore { + readonly subscriptions = new Map(); + + async put(subscription: Subscription): Promise { + this.subscriptions.set(subscription.id, { ...subscription }); + } + + async get(id: string): Promise { + const subscription = this.subscriptions.get(id); + return subscription && subscription.expiresAt > Date.now() ? { ...subscription } : null; + } + + async delete({ id }: Pick): Promise { + this.subscriptions.delete(id); + } + + async find(event: string): Promise { + return [...this.subscriptions.values()] + .filter((sub) => sub.event === event && sub.expiresAt > Date.now()) + .map((sub) => ({ ...sub })); + } +} + +/** + * Sends each delivery from the calling process, once, and records whether it finished. It is + * connected when the layer's `createDeliveryHandler()` is called. + */ +export class InlineDelivery implements EventDelivery { + readonly outcomes: { job: DeliveryJob; done: boolean }[] = []; + private send: SendJob | undefined; + + async enqueue(jobs: DeliveryJob[]): Promise { + const send = this.send; + if (!send) throw new Error("Call events.createDeliveryHandler() first"); + for (const job of jobs) this.outcomes.push({ job, done: await send(job) }); + } + + createDeliveryHandler(send: SendJob): () => Promise { + this.send = send; + return async () => new Response("inline delivery: jobs are sent on enqueue"); + } } diff --git a/packages/mcp-toolkit/src/upstash.ts b/packages/mcp-toolkit/src/upstash.ts index 3e6d684..31c6bbd 100644 --- a/packages/mcp-toolkit/src/upstash.ts +++ b/packages/mcp-toolkit/src/upstash.ts @@ -5,9 +5,6 @@ export { RedisTaskStore, QStashDispatcher, - DEFAULT_RETRIES, - DEFAULT_RETRY_DELAY, - DEFAULT_TASK_PREFIX, type RedisTaskStoreConfig, type QStashDispatcherConfig, } from "./tasks/backends/qstash.js"; @@ -17,10 +14,6 @@ export { WorkflowDispatcher, type WorkflowDispatcherConfig } from "./tasks/backe export { RedisSubscriptionStore, QStashDelivery, - DEFAULT_EVENTS_PREFIX, type RedisSubscriptionStoreConfig, type QStashDeliveryConfig, } from "./events/backends/qstash.js"; - -export { SDK_TELEMETRY } from "./telemetry.js"; -export { VERSION } from "./version.js"; From 39c861aa38d42f97a8c8092c978a11608e0e1ce8 Mon Sep 17 00:00:00 2001 From: Cahid Arda Date: Thu, 8 Oct 2026 15:12:03 +0000 Subject: [PATCH 29/33] DX-3022: mcp-toolkit: default task TTL 1 day The TTL counts from creation and is never extended, so the old 5-minute default let a slow task plus its QStash retries expire mid-run and read as "unknown task". The demo servers now use the default. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ --- CLAUDE.md | 3 +++ examples/mcp-toolkit-demo/app/lib/qstash-server.ts | 1 - examples/mcp-toolkit-demo/app/lib/workflow-server.ts | 2 -- packages/mcp-toolkit/README.md | 10 +++++----- packages/mcp-toolkit/src/tasks/core.test.ts | 7 +++++++ packages/mcp-toolkit/src/tasks/core.ts | 5 +++-- 6 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index faa8898..ee619e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -729,6 +729,9 @@ implements TanStack AI's own backend contracts (see its section below) — keep ids are always `crypto.randomUUID()`, so `TaskStore.create` is a plain `MULTI` (`HSET` + `PEXPIRE`) returning `void` — no create-if-absent script, since a collision cannot happen. `ttlMs` is always a positive number (`null` = unlimited was removed: it let tasks live forever). +- **Default task TTL is 1 day (changed 2026-10-08 from 5 minutes).** The TTL counts from creation + and is never extended, so 5 minutes let a slow task plus its QStash retries expire mid-run and + read as "unknown task". The demo servers use the default. - **Signing keys required, no fail-open.** `QStashDispatcher`, `WorkflowDispatcher` and `QStashDelivery` verify with `receiver` or a `Receiver` from the `QSTASH_*_SIGNING_KEY` env vars, and throw on the first request when neither exists (resolved outside the verify `try`, so it is diff --git a/examples/mcp-toolkit-demo/app/lib/qstash-server.ts b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts index e62ca09..d7accda 100644 --- a/examples/mcp-toolkit-demo/app/lib/qstash-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts @@ -26,7 +26,6 @@ export const dispatcher = new QStashDispatcher({ url: EXECUTE_URL }); export const tasks = createTaskLayer({ store: new RedisTaskStore({ prefix: "mcp:task:qstash:" }), dispatcher, - defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, // Who is calling. The demo has no login, so every caller is the same user — said explicitly, // because there is no anonymous default. A real server returns its user id from `auth`, and // throws when there is none (see "Who is calling" in the toolkit README). diff --git a/examples/mcp-toolkit-demo/app/lib/workflow-server.ts b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts index 4c800da..b19b5bd 100644 --- a/examples/mcp-toolkit-demo/app/lib/workflow-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts @@ -26,8 +26,6 @@ export const dispatcher = new WorkflowDispatcher({ url: EXECUTE_URL }); 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 }, // Who is calling. The demo has no login, so every caller is the same user — said explicitly, // because there is no anonymous default. A real server returns its user id from `auth`, and // throws when there is none (see "Who is calling" in the toolkit README). diff --git a/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md index d667f3d..2078c05 100644 --- a/packages/mcp-toolkit/README.md +++ b/packages/mcp-toolkit/README.md @@ -127,7 +127,7 @@ where it checks. ```jsonc // generate_report: a handle, right away { "content": [{ "type": "text", "text": "Started task 0e30…. Call task_status with taskId \"0e30…\" in about 2s to check on it." }], - "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 300000, "pollIntervalMs": 2000 } } + "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 86400000, "pollIntervalMs": 2000 } } // task_status: progress… { "content": [{ "type": "text", "text": "Task 0e30… is working: Reading sources. Check again in about 2s." }], @@ -143,7 +143,7 @@ The states are `working`, `completed`, `failed` and `cancelled`; the last three task object has the same shape as the one in the MCP Tasks extension. If the model stops polling, nothing is lost: the work finishes anyway, and the result can be read -until the task expires (5 minutes by default; set `defaults: { ttlMs }` on the layer). +until the task expires (1 day by default; set `defaults: { ttlMs }` on the layer).
@@ -162,7 +162,6 @@ export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), principal, - defaults: { ttlMs: 60 * 60 * 1000 }, // keep the record longer than the work takes }); tasks.define( @@ -191,7 +190,8 @@ every step, and finished steps are replayed from the journal: - Each step must still fit within your function's time limit. The task's TTL starts when the task is created and is never extended. When it runs out, the record -is deleted and `isCancelled()` returns true, so set `ttlMs` longer than the work takes. +is deleted and `isCancelled()` returns true. The default is 1 day; set `ttlMs` higher for work that +can take longer. | | `QStashDispatcher` | `WorkflowDispatcher` | | ---------------------------------- | -------------------------- | ------------------------------- | @@ -389,7 +389,7 @@ The toolkit never stores the caller's token, the request, the plaintext webhook All options **`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: -`defaults.ttlMs` (5 min) and `defaults.pollIntervalMs` (2s). +`defaults.ttlMs` (1 day) and `defaults.pollIntervalMs` (2s). **`tasks.define(name, config, handler)`**: `description` and `inputSchema` are required. Optional: `title`, `completedMessage`. diff --git a/packages/mcp-toolkit/src/tasks/core.test.ts b/packages/mcp-toolkit/src/tasks/core.test.ts index 08052ef..43ed48e 100644 --- a/packages/mcp-toolkit/src/tasks/core.test.ts +++ b/packages/mcp-toolkit/src/tasks/core.test.ts @@ -524,6 +524,13 @@ describe("createTaskLayer over MCP", () => { expect(manual.cancelled).toContain(taskId); }); + it("defaults to a one-day ttl and a 2s poll interval", async () => { + live = await harness(steppedHandler(1, 1)); + const result = await live.call("generate_report", { topic: "x" }); + expect(result.structuredContent).toMatchObject({ ttlMs: 86_400_000, pollIntervalMs: 2_000 }); + await live.dispatcher.drain(); + }); + it("uses the layer's default ttl and poll interval", async () => { live = await harness(steppedHandler(1, 1), { defaults: { ttlMs: 60_000, pollIntervalMs: 5_000 }, diff --git a/packages/mcp-toolkit/src/tasks/core.ts b/packages/mcp-toolkit/src/tasks/core.ts index 294d48f..447bb62 100644 --- a/packages/mcp-toolkit/src/tasks/core.ts +++ b/packages/mcp-toolkit/src/tasks/core.ts @@ -22,7 +22,8 @@ import type { WireTask, } from "./types.js"; -const DEFAULT_TTL_MS = 300_000; +/** One day: long enough for slow work plus retries, and for the model to come back for the result. */ +const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_POLL_INTERVAL_MS = 2_000; const STATUS_TOOL = "task_status"; const CANCEL_TOOL = "task_cancel"; @@ -38,7 +39,7 @@ export type TaskLayerOptions = { */ principal: PrincipalResolver; defaults?: { - /** Retention window from creation. Defaults to 5 minutes. */ + /** Retention window from creation. Defaults to 1 day. */ ttlMs?: number; /** Poll interval suggested to the model. Defaults to 2s. */ pollIntervalMs?: number; From 9d56f825b8983623ae82da5f9579e4796b95c46f Mon Sep 17 00:00:00 2001 From: Cahid Arda Date: Thu, 8 Oct 2026 15:26:53 +0000 Subject: [PATCH 30/33] DX-3022: mcp-toolkit: Workflow route runs a step before the task Workflow authorizes every request, the failure callback included, by running the route function until its first step, and refuses one that throws or returns before any. A handler that threw before its first task.run never reached failureFunction, so the task read `working` until its TTL. The route now runs a `mcp-task:start` step first. Caught by the new e2e failure check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ --- .../src/tasks/backends/workflow.test.ts | 39 ++++++++++++- .../src/tasks/backends/workflow.ts | 57 ++++++++++++------- 2 files changed, 73 insertions(+), 23 deletions(-) diff --git a/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts b/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts index 8f5274c..470f872 100644 --- a/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts +++ b/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts @@ -9,7 +9,7 @@ import { Client as QStashClient } from "@upstash/qstash"; import { WorkflowContext } from "@upstash/workflow"; import { describe, expect, it } from "vitest"; import * as z from "zod"; -import { insideStep, WorkflowDispatcher } from "./workflow.js"; +import { insideStep, WorkflowDispatcher, workflowRoute } from "./workflow.js"; import { createTaskLayer } from "../core.js"; import type { Task, TaskContext } from "../types.js"; import { @@ -276,3 +276,40 @@ describe("insideStep", () => { expect(insideStep(context as never)).toBe(false); }); }); + +describe("workflowRoute", () => { + // Workflow authorizes every request, the failure callback included, by running the route + // function on a context whose first step throws a sentinel. A route that throws anything else + // first, or returns without a step, is refused, and failureFunction never runs. + it("reaches a step before any task code runs", async () => { + const steps: string[] = []; + let ran = false; + const authorizing = { + requestPayload: { taskId: "task-1" }, + run: async (name: string) => { + steps.push(name); + throw new Error("first step reached"); + }, + }; + const route = workflowRoute({ + run: async () => { + ran = true; + throw new Error("the handler threw before its first step"); + }, + fail: async () => undefined, + }); + await expect(route(authorizing as never)).rejects.toThrow("first step reached"); + expect(steps).toEqual(["mcp-task:start"]); + expect(ran).toBe(false); + }); + + it("still reaches a step when there is no task to run", async () => { + const steps: string[] = []; + const route = workflowRoute({ run: async () => undefined, fail: async () => undefined }); + await route({ + requestPayload: {}, + run: async (name: string, fn: () => Promise) => (steps.push(name), fn()), + } as never); + expect(steps).toEqual(["mcp-task:start"]); + }); +}); diff --git a/packages/mcp-toolkit/src/tasks/backends/workflow.ts b/packages/mcp-toolkit/src/tasks/backends/workflow.ts index d875e9c..08fdd3e 100644 --- a/packages/mcp-toolkit/src/tasks/backends/workflow.ts +++ b/packages/mcp-toolkit/src/tasks/backends/workflow.ts @@ -93,33 +93,46 @@ export class WorkflowDispatcher implements TaskDispatcher( - async (context) => { - const taskId = context.requestPayload?.taskId; - if (taskId) await endpoints.run(taskId, context, journalFor(context)); - }, - { - failureFunction: async ({ context, failStatus, failResponse }) => { - 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 }, - }); - }, - // Workflow verifies only body and signature; binding the URL refuses a signature that - // QStash issued for any other endpoint of the same account. - receiver: boundToUrl(receiver, url), - qstashClient, - // The public URL, as with QStash: behind a proxy `request.url` is the internal one. - url, + const { handler } = serve(workflowRoute(endpoints), { + failureFunction: async ({ context, failStatus, failResponse }) => { + 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 }, + }); }, - ); + // Workflow verifies only body and signature; binding the URL refuses a signature that + // QStash issued for any other endpoint of the same account. + receiver: boundToUrl(receiver, url), + qstashClient, + // The public URL, as with QStash: behind a proxy `request.url` is the internal one. + url, + }); return handler; } } +/** + * The workflow's route function. Its first act is always a step: Workflow authorizes every request, + * the failure callback included, by running the route function until its first step, and refuses + * one that throws or returns before reaching any. Without this, a handler that throws before its + * first `task.run` (or a run whose task was already cancelled) never gets `failureFunction`, and + * the task reads `working` until its TTL. + */ +export function workflowRoute( + endpoints: TaskEndpoints>, +): (context: WorkflowContext) => Promise { + return async (context) => { + const taskId = await context.run( + "mcp-task:start", + async () => context.requestPayload?.taskId ?? null, + ); + if (taskId) await endpoints.run(taskId, context, journalFor(context)); + }; +} + /** * Journals the core's own writes at the top level only: Workflow rejects a step inside a step, and * a handler may call `task.update` from inside its own `task.run`. From a8a287931e3c202e079e621420709f8aee738d0c Mon Sep 17 00:00:00 2001 From: Cahid Arda Date: Thu, 8 Oct 2026 15:27:20 +0000 Subject: [PATCH 31/33] DX-3022: mcp-toolkit demo: demo users, e2e suite in CI - Demo auth: `Bearer demo-` is the user `demo-`, passed to the SDK as authInfo (no header = `demo-user`, so the page and ChatGPT keep working). - Deploy Watch's authorize keeps production deploys from `demo-intern`. - `pnpm e2e` starts the QStash dev server and the built app and runs scripts/smoke.mjs against both task servers. New checks: cross-user isolation, 401, unknown UUID, the failure path, unsigned/forged deliveries, per-user subscriptions, authorize at delivery, 410 deleting a subscription. - The CI step that runs it is in a separate change (the GitHub App token here cannot push workflow files). - Cleanup: drop the unused TASKS_DRIVER, input_required and nullable ttlMs, and fix README claims about cancel and dead-lettered tasks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ --- CLAUDE.md | 18 +- examples/mcp-toolkit-demo/.env.example | 5 - examples/mcp-toolkit-demo/README.md | 47 ++- .../app/api/deploy-watch/route.ts | 4 +- .../app/api/mcp-workflow/route.ts | 4 +- .../mcp-toolkit-demo/app/api/mcp/route.ts | 4 +- .../app/api/receiver/route.ts | 7 + examples/mcp-toolkit-demo/app/globals.css | 4 - examples/mcp-toolkit-demo/app/lib/auth.ts | 41 ++ .../mcp-toolkit-demo/app/lib/deploy-watch.ts | 15 +- examples/mcp-toolkit-demo/app/lib/e2e.ts | 16 + .../mcp-toolkit-demo/app/lib/mcp-client.ts | 6 +- .../mcp-toolkit-demo/app/lib/qstash-server.ts | 17 +- .../app/lib/workflow-server.ts | 16 +- examples/mcp-toolkit-demo/app/page.tsx | 14 +- examples/mcp-toolkit-demo/package.json | 3 +- examples/mcp-toolkit-demo/scripts/e2e.mjs | 105 +++++ examples/mcp-toolkit-demo/scripts/smoke.mjs | 360 +++++++++++++----- 18 files changed, 530 insertions(+), 156 deletions(-) create mode 100644 examples/mcp-toolkit-demo/app/lib/auth.ts create mode 100644 examples/mcp-toolkit-demo/app/lib/e2e.ts create mode 100644 examples/mcp-toolkit-demo/scripts/e2e.mjs diff --git a/CLAUDE.md b/CLAUDE.md index ee619e7..f5f224b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -729,6 +729,12 @@ implements TanStack AI's own backend contracts (see its section below) — keep ids are always `crypto.randomUUID()`, so `TaskStore.create` is a plain `MULTI` (`HSET` + `PEXPIRE`) returning `void` — no create-if-absent script, since a collision cannot happen. `ttlMs` is always a positive number (`null` = unlimited was removed: it let tasks live forever). +- **Workflow route steps first (fixed 2026-10-08, caught by the e2e failure check).** Workflow + authorizes every request, the failure callback included, by running the route function on a + `DisabledWorkflowContext` until its first step; a route that throws or returns before any step + is refused (`Not authorized to run the failure function`). A handler that threw before its first + `task.run` therefore never reached `failureFunction` and stayed `working` until its TTL. + `workflowRoute` now runs a `mcp-task:start` step before anything else. - **Default task TTL is 1 day (changed 2026-10-08 from 5 minutes).** The TTL counts from creation and is never extended, so 5 minutes let a slow task plus its QStash retries expire mid-run and read as "unknown task". The demo servers use the default. @@ -825,9 +831,15 @@ implements TanStack AI's own backend contracts (see its section below) — keep Vercel Workflow's `World = Storage + Queue + Streamer`. - Tests: `src/{tasks,events}/core.test.ts` drive a real `McpServer` through `createMcpHandler` over genuine JSON-RPC; `src/*/backends/qstash.test.ts` hit real Redis. All run under the root vitest - config. The demo's `pnpm smoke` is the end-to-end check (QStash dev server + both servers + the - filtered `deploy.finished` webhook from the Deploy Watch server into the demo's receiver). The demo - keeps tasks (Report Desk, `/api/mcp`) and events (Deploy Watch, `/api/deploy-watch`) in separate + config. The demo's `pnpm e2e` (`scripts/e2e.mjs`) is the end-to-end check and runs in CI after the + example build: it starts the QStash dev server (:8181) and `next start` (:3100) with + `MCP_TOOLKIT_E2E=1`, then runs `scripts/smoke.mjs` against `/api/mcp` and `/api/mcp-workflow`. No + model. Users are `Bearer demo-` (`app/lib/auth.ts`; no header = `demo-user`). It covers + completion, cancel, cross-user isolation, 401, the failure path (`always_fail`, defined only under + `MCP_TOOLKIT_E2E` with retries cut), refused unsigned/forged deliveries, filtered and signed + webhooks, `authorize` at delivery (`demo-intern` never gets production), and 410 deleting the + subscription (checked in Redis). `pnpm smoke` alone runs the same checks against a running app. + The demo keeps tasks (Report Desk, `/api/mcp`) and events (Deploy Watch, `/api/deploy-watch`) in separate servers on purpose. The events tests stub the **global** `fetch` (`vi.stubGlobal`) — the layer has no `fetch` option. - **Events facts.** Wire format follows ChatGPT's MCP Events (webhook only): `events/list|subscribe| diff --git a/examples/mcp-toolkit-demo/.env.example b/examples/mcp-toolkit-demo/.env.example index 4c5eed7..d66dba8 100644 --- a/examples/mcp-toolkit-demo/.env.example +++ b/examples/mcp-toolkit-demo/.env.example @@ -19,8 +19,3 @@ MCP_EVENTS_SECRET_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-toolkit-demo/README.md b/examples/mcp-toolkit-demo/README.md index cdfac8b..c672255 100644 --- a/examples/mcp-toolkit-demo/README.md +++ b/examples/mcp-toolkit-demo/README.md @@ -21,8 +21,11 @@ no capabilities at all. It speaks raw stateless JSON-RPC and shows every frame i | `app/api/deploy-watch/events/route.ts` | Where QStash delivers each event. One line: the delivery owns the endpoint | | `app/api/deploy-watch/deploys/route.ts` | Report a deploy (stands in for your CI), which fires `deploy.finished` | | `app/api/receiver/route.ts` | A stand-in for the host's webhook receiver, so events can be seen without a public URL | +| `app/lib/auth.ts` | Stand-in authentication: `Bearer demo-` is user `demo-`, no header is `demo-user` | | `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 | +| `scripts/smoke.mjs` | The e2e checks, as raw JSON-RPC against a running app | +| `scripts/e2e.mjs` | Starts the QStash dev server and the built app, and runs the checks against both task servers | +| `app/lib/e2e.ts` | Test hooks for `scripts/e2e.mjs` only (`MCP_TOOLKIT_E2E=1`) | ## Run it @@ -44,13 +47,25 @@ Then open http://localhost:3000, type a topic, and hit **Run tool**. 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: +## Tests + +No model and no QStash account: the checks speak raw JSON-RPC, as a model and a host would. ```bash -pnpm smoke # tasks: happy path, cancel, unknown id · events: a filtered deploy.finished webhook -MCP_PATH=/api/mcp-workflow pnpm smoke # the same against the Workflow server +pnpm build && pnpm e2e # starts the QStash dev server and the app, runs everything, stops them ``` +Against both task servers it checks a task running to completion, cancel, another user's task +reading as unknown (`Bearer demo-alice` vs `Bearer demo-bob`), a bad token getting 401, a task that +throws ending `failed`, and unsigned or forged deliveries being refused. Against Deploy Watch: a +filtered subscription getting exactly one validly signed webhook, another user's unsubscribe +changing nothing, `authorize` refusing `demo-intern` production deploys at subscribe time and at +delivery, and a host's 410 deleting the subscription. CI runs it on every PR. + +Against an app you already have running (`pnpm dev` + `pnpm qstash`), `pnpm smoke` runs the same +checks, and `MCP_PATH=/api/mcp-workflow pnpm smoke` the Workflow ones. The failure check is +skipped there unless the app was started with `MCP_TOOLKIT_E2E=1`. + ## Two separate tests The demo serves two unrelated MCP servers, so tasks and events can be tried on their own: @@ -83,8 +98,8 @@ durability differs: request. Once it completes, `task_status` returns the report's own content. **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 +to `cancelled`, any redelivery finds the task settled and does nothing (on Workflow the run itself +is cancelled), 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: @@ -101,17 +116,19 @@ record; QStash's redelivery is what finished the work. Replace the dispatcher wi 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. +retries on its configured schedule, and when the attempts run out its failure callback marks the +task `failed`. With a flat one-second delay every attempt is spent within a few seconds, long +before a dev server is back up. 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. A failed message stays in +the QStash DLQ. ## Notes - All three tools are ordinary MCP tools, so this works in every client today — Claude Code, Codex, Cursor, OpenCode, ChatGPT — none of which declare the protocol's Tasks extension yet. -- The demo has no login, so both layers use `principal: () => "demo-user"`: every caller is the - same user. A multi-user server returns its user id from the request's auth instead, so one user - cannot read or cancel another's task or subscribe as someone else — see the package README. +- The demo has no real login. `app/lib/auth.ts` stands in for one: the MCP routes turn + `Authorization: Bearer demo-` into the user `demo-` and pass it to the SDK as + `authInfo`, and `principal` reads it back, as a real server would with a verified token. With no + header the caller is the shared `demo-user`, so the page and an unauthenticated ChatGPT + connection keep working; a real server answers 401 instead. diff --git a/examples/mcp-toolkit-demo/app/api/deploy-watch/route.ts b/examples/mcp-toolkit-demo/app/api/deploy-watch/route.ts index 628cf17..fc7be2b 100644 --- a/examples/mcp-toolkit-demo/app/api/deploy-watch/route.ts +++ b/examples/mcp-toolkit-demo/app/api/deploy-watch/route.ts @@ -1,5 +1,6 @@ /** The MCP endpoint for **Deploy Watch**, the events demo. */ import { createMcpHandler } from "@modelcontextprotocol/server"; +import { serveMcp } from "../../lib/auth"; import { createServer } from "../../lib/deploy-watch"; export const dynamic = "force-dynamic"; @@ -7,5 +8,6 @@ export const dynamic = "force-dynamic"; const handler = createMcpHandler(() => createServer()); export async function POST(request: Request): Promise { - return handler.fetch(request); + // Verifies the caller and passes `authInfo` to the SDK, which hands it to `principal`. + return serveMcp(handler, request); } diff --git a/examples/mcp-toolkit-demo/app/api/mcp-workflow/route.ts b/examples/mcp-toolkit-demo/app/api/mcp-workflow/route.ts index f8e4bc0..807d5f5 100644 --- a/examples/mcp-toolkit-demo/app/api/mcp-workflow/route.ts +++ b/examples/mcp-toolkit-demo/app/api/mcp-workflow/route.ts @@ -1,5 +1,6 @@ /** The MCP endpoint for the **Workflow** server. Same handler, different task layer. */ import { createMcpHandler } from "@modelcontextprotocol/server"; +import { serveMcp } from "../../lib/auth"; import { createServer } from "../../lib/workflow-server"; export const dynamic = "force-dynamic"; @@ -7,5 +8,6 @@ export const dynamic = "force-dynamic"; const handler = createMcpHandler(() => createServer()); export async function POST(request: Request): Promise { - return handler.fetch(request); + // Verifies the caller and passes `authInfo` to the SDK, which hands it to `principal`. + return serveMcp(handler, request); } diff --git a/examples/mcp-toolkit-demo/app/api/mcp/route.ts b/examples/mcp-toolkit-demo/app/api/mcp/route.ts index 0bf7c09..3daf2f3 100644 --- a/examples/mcp-toolkit-demo/app/api/mcp/route.ts +++ b/examples/mcp-toolkit-demo/app/api/mcp/route.ts @@ -5,6 +5,7 @@ * needs no custom transport and no extra protocol methods. */ import { createMcpHandler } from "@modelcontextprotocol/server"; +import { serveMcp } from "../../lib/auth"; import { createServer } from "../../lib/qstash-server"; export const dynamic = "force-dynamic"; @@ -12,5 +13,6 @@ export const dynamic = "force-dynamic"; const handler = createMcpHandler(() => createServer()); export async function POST(request: Request): Promise { - return handler.fetch(request); + // Verifies the caller and passes `authInfo` to the SDK, which hands it to `principal`. + return serveMcp(handler, request); } diff --git a/examples/mcp-toolkit-demo/app/api/receiver/route.ts b/examples/mcp-toolkit-demo/app/api/receiver/route.ts index 8376adb..2ff0519 100644 --- a/examples/mcp-toolkit-demo/app/api/receiver/route.ts +++ b/examples/mcp-toolkit-demo/app/api/receiver/route.ts @@ -6,6 +6,8 @@ * - `POST ?id=…` is the callback: it checks the Standard Webhooks signature, answers the * verification challenge, and records every event. * - `GET ?id=…` returns what it received. + * - `DELETE ?id=…` forgets the receiver, so its next POST answers 410, which tells the server to + * delete the subscription (the way a host drops one). * * State is in process memory: fine for `next dev`, not for anything real. */ @@ -37,6 +39,11 @@ export async function POST(request: Request): Promise { return new Response(null, { status: 204 }); } +export async function DELETE(request: Request): Promise { + receivers.delete(idOf(request)); + return new Response(null, { status: 204 }); +} + export async function GET(request: Request): Promise { return Response.json(receivers.get(idOf(request))?.received ?? []); } diff --git a/examples/mcp-toolkit-demo/app/globals.css b/examples/mcp-toolkit-demo/app/globals.css index 20c3add..6e5af2e 100644 --- a/examples/mcp-toolkit-demo/app/globals.css +++ b/examples/mcp-toolkit-demo/app/globals.css @@ -216,10 +216,6 @@ button.ghost:hover:not(:disabled) { color: var(--blue); } -.badge.input_required { - color: var(--amber); -} - .badge.completed { color: var(--accent); } diff --git a/examples/mcp-toolkit-demo/app/lib/auth.ts b/examples/mcp-toolkit-demo/app/lib/auth.ts new file mode 100644 index 0000000..794efa8 --- /dev/null +++ b/examples/mcp-toolkit-demo/app/lib/auth.ts @@ -0,0 +1,41 @@ +/** + * The demo's stand-in for real authentication (Clerk, WorkOS, Auth0, your own). + * + * - `Authorization: Bearer demo-` signs in as the user `demo-`, so the e2e script can act + * as several users and check that they can't see each other's tasks. + * - No header at all is the shared `demo-user`, so the browser page and an unauthenticated ChatGPT + * connection keep working. + * - Any other header is refused with 401. + * + * A real server verifies a real token here, and answers 401 when there is none. + */ +import type { AuthInfo } from "@modelcontextprotocol/server"; + +const DEMO_TOKEN = /^Bearer (demo-[a-z0-9-]{1,32})$/; + +/** The verified caller, or `null` when the request carries a token we don't accept. */ +export function verifyDemoToken(request: Request): AuthInfo | null { + const header = request.headers.get("authorization"); + const userId = header === null ? "demo-user" : DEMO_TOKEN.exec(header)?.[1]; + if (!userId) return null; + return { token: header ?? "anonymous", clientId: "mcp-toolkit-demo", scopes: [], extra: { userId } }; +} + +/** `principal` for both layers: the user id `verifyDemoToken` put in `extra`. */ +export function principal({ auth }: { auth?: AuthInfo }): string { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; +} + +/** Verifies the caller and hands the request to an MCP handler, or answers 401. */ +export async function serveMcp( + handler: { fetch(request: Request, options: { authInfo: AuthInfo }): Promise }, + request: Request, +): Promise { + const authInfo = verifyDemoToken(request); + if (!authInfo) { + return new Response("Unauthorized", { status: 401, headers: { "www-authenticate": "Bearer" } }); + } + return handler.fetch(request, { authInfo }); +} diff --git a/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts index e15be20..d0feb15 100644 --- a/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts +++ b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts @@ -14,17 +14,17 @@ import { createEventLayer } from "@upstash/mcp-toolkit/events"; import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/upstash"; import { Redis } from "@upstash/redis"; import * as z from "zod"; +import { principal } from "./auth"; const APP_URL = process.env.APP_URL ?? "http://127.0.0.1:3000"; const RECENT_KEY = "deploy-watch:recent"; -/** The demo has no login, so every caller is this one user. A real server returns its user id. */ -const DEMO_USER = "demo-user"; export const events = createEventLayer({ store: new RedisSubscriptionStore({ prefix: "deploy-watch:events:" }), delivery: new QStashDelivery({ url: `${APP_URL}/api/deploy-watch/events` }), // `secretKey` is read from MCP_EVENTS_SECRET_KEY (see .env.example). There is no fallback. - principal: () => DEMO_USER, + // Who is calling: the user id the MCP route verified (see `auth.ts`). + principal, // The demo's own receiver runs on localhost. Never set this in production: it disables the // checks that stop a subscriber from pointing your server at internal addresses. allowInsecureCallbacks: /^http:\/\/(127\.0\.0\.1|localhost)/.test(APP_URL), @@ -51,9 +51,12 @@ export const deployFinished = events.define("deploy.finished", { environment: z.enum(["production", "staging"]).optional().describe("Only this environment"), }), payload: Deploy, - // Everyone may watch every deploy in the demo. A real server checks whether this user can see - // the service, and the check runs again before each delivery, so revoked access stops events. - authorize: () => true, + // The demo's permission rule, standing in for yours: `demo-intern` may not see production. + // At subscribe time `args` is the filter, so an intern can't ask for production. Before every + // delivery `args` is the deploy's own values, so an intern who subscribed to everything (`{}`) + // still only receives staging deploys. + authorize: (args, { principal }) => + principal !== "demo-intern" || args.environment !== "production", }); /** Records a deploy and fires `deploy.finished` to every matching subscription `authorize` allows. */ diff --git a/examples/mcp-toolkit-demo/app/lib/e2e.ts b/examples/mcp-toolkit-demo/app/lib/e2e.ts new file mode 100644 index 0000000..770a29b --- /dev/null +++ b/examples/mcp-toolkit-demo/app/lib/e2e.ts @@ -0,0 +1,16 @@ +/** + * Test hooks for `scripts/e2e.mjs`, off unless `MCP_TOOLKIT_E2E=1`. Never set it in a deployment. + * + * With it on, each task server also defines `always_fail`, and retries are cut so a failing task + * reaches `failed` in seconds instead of after the default two-minute backoff. + */ +export const E2E = process.env.MCP_TOOLKIT_E2E === "1"; + +export const ALWAYS_FAIL = { + title: "Always fail", + description: "Always throws. Only defined for the e2e script, to test the failure path.", +} as const; + +export async function alwaysFail(): Promise { + throw new Error("This task always fails (e2e)."); +} diff --git a/examples/mcp-toolkit-demo/app/lib/mcp-client.ts b/examples/mcp-toolkit-demo/app/lib/mcp-client.ts index 671474c..8d99668 100644 --- a/examples/mcp-toolkit-demo/app/lib/mcp-client.ts +++ b/examples/mcp-toolkit-demo/app/lib/mcp-client.ts @@ -18,7 +18,7 @@ export const SERVERS = { export type ServerKey = keyof typeof SERVERS; -export type TaskStatus = "working" | "input_required" | "completed" | "failed" | "cancelled"; +export type TaskStatus = "working" | "completed" | "failed" | "cancelled"; export type WireTask = { taskId: string; @@ -26,8 +26,8 @@ export type WireTask = { statusMessage?: string; createdAt: string; lastUpdatedAt: string; - ttlMs: number | null; - pollIntervalMs?: number; + ttlMs: number; + pollIntervalMs: number; result?: Record; error?: { code: number; message: string; data?: unknown }; }; diff --git a/examples/mcp-toolkit-demo/app/lib/qstash-server.ts b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts index d7accda..7bb63d4 100644 --- a/examples/mcp-toolkit-demo/app/lib/qstash-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts @@ -11,13 +11,18 @@ import { McpServer } from "@modelcontextprotocol/server"; import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; +import { principal } from "./auth"; +import { ALWAYS_FAIL, E2E, alwaysFail } from "./e2e"; /** 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 }); +// outlives a restart instead of failing while the server is down. (The e2e run shortens them.) +export const dispatcher = new QStashDispatcher({ + url: EXECUTE_URL, + ...(E2E ? { retries: 1, retryDelay: "1000" } : {}), +}); /** * No type argument: a queue adds nothing to the handler's context, so the handler receives just @@ -26,10 +31,8 @@ export const dispatcher = new QStashDispatcher({ url: EXECUTE_URL }); export const tasks = createTaskLayer({ store: new RedisTaskStore({ prefix: "mcp:task:qstash:" }), dispatcher, - // Who is calling. The demo has no login, so every caller is the same user — said explicitly, - // because there is no anonymous default. A real server returns its user id from `auth`, and - // throws when there is none (see "Who is calling" in the toolkit README). - principal: () => "demo-user", + // Who is calling: the user id the MCP route verified (see `auth.ts`). Throws when there is none. + principal, }); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -76,6 +79,8 @@ tasks.define( }, ); +if (E2E) tasks.define("always_fail", { ...ALWAYS_FAIL, inputSchema: z.object({}) }, alwaysFail); + /** A fresh server per request: the defined task tool plus `task_status` and `task_cancel`. */ export function createServer(): McpServer { const server = new McpServer({ name: "report-desk", version: "0.1.0" }); diff --git a/examples/mcp-toolkit-demo/app/lib/workflow-server.ts b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts index b19b5bd..d893e9f 100644 --- a/examples/mcp-toolkit-demo/app/lib/workflow-server.ts +++ b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts @@ -13,11 +13,17 @@ import { McpServer } from "@modelcontextprotocol/server"; import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; +import { principal } from "./auth"; +import { ALWAYS_FAIL, E2E, alwaysFail } from "./e2e"; /** 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 }); +export const dispatcher = new WorkflowDispatcher({ + url: EXECUTE_URL, + // The e2e run fails fast; otherwise the Workflow SDK default applies. + ...(E2E ? { retries: 0 } : {}), +}); /** * No type argument needed: the context type is inferred from the dispatcher and flows into @@ -26,10 +32,8 @@ export const dispatcher = new WorkflowDispatcher({ url: EXECUTE_URL }); export const tasks = createTaskLayer({ store: new RedisTaskStore({ prefix: "mcp:task:workflow:" }), dispatcher, - // Who is calling. The demo has no login, so every caller is the same user — said explicitly, - // because there is no anonymous default. A real server returns its user id from `auth`, and - // throws when there is none (see "Who is calling" in the toolkit README). - principal: () => "demo-user", + // Who is calling: the user id the MCP route verified (see `auth.ts`). Throws when there is none. + principal, }); const STEPS = 4; @@ -67,6 +71,8 @@ tasks.define( }, ); +if (E2E) tasks.define("always_fail", { ...ALWAYS_FAIL, inputSchema: z.object({}) }, alwaysFail); + export function createServer(): McpServer { const server = new McpServer({ name: "mcp-toolkit-demo-workflow", version: "0.1.0" }); tasks.register(server); diff --git a/examples/mcp-toolkit-demo/app/page.tsx b/examples/mcp-toolkit-demo/app/page.tsx index 08acb71..4fcd377 100644 --- a/examples/mcp-toolkit-demo/app/page.tsx +++ b/examples/mcp-toolkit-demo/app/page.tsx @@ -61,7 +61,7 @@ export default function Page() { 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; + if (now - task.lastPolledAt < task.wire.pollIntervalMs) continue; markPolled(task.taskId); void poll(task.taskId, task.server); } @@ -265,8 +265,8 @@ function TaskCard({ task, onCancel }: { task: TrackedTask; onCancel: () => void
{elapsed}s elapsed {task.polls} polls - ttl {wire.ttlMs === null ? "∞" : `${Math.round(wire.ttlMs / 1000)}s`} - every {wire.pollIntervalMs ?? 2000}ms + kept for {formatDuration(wire.ttlMs)} + every {wire.pollIntervalMs}ms
{wire.result ? ( @@ -309,3 +309,11 @@ function readProgress(wire: WireTask): number { const [, step, total] = match; return Math.round((Number(step) / Number(total)) * 100); } + +/** How long the task record is kept, e.g. "1d", "2h", "5min". */ +function formatDuration(ms: number): string { + const minutes = Math.round(ms / 60_000); + if (minutes < 60) return `${Math.max(1, minutes)}min`; + const hours = Math.round(minutes / 60); + return hours % 24 === 0 ? `${hours / 24}d` : `${hours}h`; +} diff --git a/examples/mcp-toolkit-demo/package.json b/examples/mcp-toolkit-demo/package.json index adae453..8e57d77 100644 --- a/examples/mcp-toolkit-demo/package.json +++ b/examples/mcp-toolkit-demo/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "qstash": "npx @upstash/qstash-cli dev", - "smoke": "node scripts/smoke.mjs" + "smoke": "node scripts/smoke.mjs", + "e2e": "node scripts/e2e.mjs" }, "dependencies": { "@modelcontextprotocol/server": "^2.0.0", diff --git a/examples/mcp-toolkit-demo/scripts/e2e.mjs b/examples/mcp-toolkit-demo/scripts/e2e.mjs new file mode 100644 index 0000000..290dd78 --- /dev/null +++ b/examples/mcp-toolkit-demo/scripts/e2e.mjs @@ -0,0 +1,105 @@ +// The whole e2e run in one command, with no model and no QStash account: starts the local QStash +// dev server and the built app, runs smoke.mjs against the QStash and the Workflow task servers +// (the first run also covers Deploy Watch), then stops everything. +// +// Needs UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN (environment or .env.local) and a built +// app (`pnpm build`). CI runs it after building the examples. +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync } from "node:fs"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +let stopping = false; +process.chdir(dirname(dirname(fileURLToPath(import.meta.url)))); +if (existsSync(".env.local")) process.loadEnvFile(".env.local"); // never overrides what is set + +for (const name of ["UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN"]) { + if (!process.env[name]) fail(`${name} is not set (environment or .env.local).`); +} +if (!existsSync(".next/BUILD_ID")) fail("No build found. Run `pnpm build` first."); + +// Off the dev defaults (3000, 8080), so a `pnpm dev` and `pnpm qstash` can keep running. +const APP_PORT = process.env.E2E_APP_PORT ?? "3100"; +const QSTASH_PORT = process.env.E2E_QSTASH_PORT ?? "8181"; +const BASE = `http://127.0.0.1:${APP_PORT}`; + +const children = []; +process.on("exit", () => { + for (const child of children) { + try { + process.kill(-child.pid, "SIGTERM"); // the whole group: npx starts the real server as a child + } catch {} + } +}); +process.on("SIGINT", () => process.exit(130)); + +/** Starts a long-running process in its own group, keeping the tail of its output for errors. */ +function start(name, command, args, env = {}) { + const child = spawn(command, args, { + env: { ...process.env, ...env }, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + child.output = ""; + const keep = (chunk) => (child.output = (child.output + chunk).slice(-8000)); + child.stdout.on("data", keep); + child.stderr.on("data", keep); + child.on("exit", (code) => { + if (!stopping) fail(`${name} exited early (code ${code}):\n${child.output}`); + }); + children.push(child); + return child; +} + +function fail(message) { + stopping = true; + console.error(`\ne2e: ${message}`); + process.exit(1); +} + +async function waitFor(what, ready, seconds = 90) { + for (let i = 0; i < seconds * 2; i++) { + const value = await ready().catch(() => undefined); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + fail(`timed out waiting for ${what}`); +} + +// 1. The QStash dev server. Its token and signing keys are read from what it prints. +const qstash = start("QStash dev server", "npx", ["-y", "@upstash/qstash-cli", "dev", "-port", QSTASH_PORT]); +const credentials = await waitFor("the QStash dev server", async () => { + const read = (name) => new RegExp(`${name}=(\\S+)`).exec(qstash.output)?.[1]; + const [token, current, next] = ["QSTASH_TOKEN", "QSTASH_CURRENT_SIGNING_KEY", "QSTASH_NEXT_SIGNING_KEY"].map(read); + return token && current && next ? { token, current, next } : undefined; +}); +console.log(`e2e: QStash dev server on :${QSTASH_PORT}`); + +// 2. The built app, pointed at it, with the e2e hooks on (app/lib/e2e.ts). +const app = start("the app", "npx", ["next", "start", "-H", "127.0.0.1", "-p", APP_PORT], { + QSTASH_URL: `http://127.0.0.1:${QSTASH_PORT}`, + QSTASH_TOKEN: credentials.token, + QSTASH_CURRENT_SIGNING_KEY: credentials.current, + QSTASH_NEXT_SIGNING_KEY: credentials.next, + APP_URL: BASE, + MCP_EVENTS_SECRET_KEY: randomBytes(32).toString("base64"), + MCP_TOOLKIT_E2E: "1", +}); +await waitFor("the app", async () => (await fetch(BASE)).status < 500); +console.log(`e2e: app on ${BASE}`); + +// 3. smoke.mjs against each task server. +for (const path of ["/api/mcp", "/api/mcp-workflow"]) { + const code = await new Promise((resolve) => { + spawn("node", ["scripts/smoke.mjs"], { + env: { ...process.env, BASE, MCP_PATH: path }, + stdio: "inherit", + }).on("exit", resolve); + }); + if (code !== 0) fail(`smoke.mjs failed against ${path}. Last app output:\n${app.output}`); +} + +stopping = true; +console.log("\ne2e: all runs passed"); +process.exit(0); diff --git a/examples/mcp-toolkit-demo/scripts/smoke.mjs b/examples/mcp-toolkit-demo/scripts/smoke.mjs index 5678f8d..79ff61c 100644 --- a/examples/mcp-toolkit-demo/scripts/smoke.mjs +++ b/examples/mcp-toolkit-demo/scripts/smoke.mjs @@ -1,12 +1,35 @@ -// Drives the demo the way a model would: plain tools/call, from a client that declares nothing. +// Drives the demo the way a model and a host would: plain JSON-RPC from a client that declares +// nothing, and no model anywhere. Run it against a running app (`pnpm smoke`), or let `pnpm e2e` +// start QStash and the app and run it against both task servers. +// +// MCP_PATH=/api/mcp the QStash task server, plus Deploy Watch (events) [default] +// MCP_PATH=/api/mcp-workflow the Workflow task server +import { randomBytes, randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { Redis } from "@upstash/redis"; + +if (existsSync(".env.local")) process.loadEnvFile(".env.local"); // never overrides what is set + 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 EXECUTE = ENDPOINT === "/api/mcp-workflow" ? "/api/execute-workflow" : "/api/execute"; +const WATCH = "/api/deploy-watch"; const PV = "2026-07-28"; -const sleep = ms => new Promise(r => setTimeout(r, ms)); +const TERMINAL = new Set(["completed", "failed", "cancelled"]); + +// Demo users: the MCP routes accept `Bearer demo-` (see app/lib/auth.ts). +const ALICE = "demo-alice"; +const BOB = "demo-bob"; +const INTERN = "demo-intern"; // Deploy Watch's authorize keeps production deploys from this one + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function check(condition, message) { + if (!condition) throw new Error(message); +} let id = 0; -async function rpc(method, params = {}, path = ENDPOINT) { +function post(path, method, params, user) { const headers = { "content-type": "application/json", accept: "application/json, text/event-stream", @@ -14,7 +37,8 @@ async function rpc(method, params = {}, path = ENDPOINT) { "mcp-method": method, }; if (params.name) headers["mcp-name"] = params.name; - const response = await fetch(`${BASE}${path}`, { + if (user) headers.authorization = `Bearer ${user}`; + return fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify({ @@ -31,116 +55,248 @@ async function rpc(method, params = {}, path = ENDPOINT) { }, }), }); +} + +async function rpc(method, params = {}, { path = ENDPOINT, user = ALICE } = {}) { + const response = await post(path, method, params, user); + check(response.ok, `${method}: HTTP ${response.status}`); const json = await response.json(); - if (json.error) throw new Error(`${method}: ${JSON.stringify(json.error)}`); + if (json.error) { + throw Object.assign(new Error(`${method}: ${JSON.stringify(json.error)}`), { rpc: json.error }); + } return json.result; } -const call = (name, args) => rpc("tools/call", { name, arguments: args }); +const call = (name, args, user = ALICE) => rpc("tools/call", { name, arguments: args }, { user }); +const statusOf = (result) => result.structuredContent?.status; +const textOf = (result) => (result.content ?? []).map((part) => part.text ?? "").join("\n"); +const isUnknown = (result) => result.isError === true && /Unknown task/.test(textOf(result)); +const brief = (result) => + JSON.stringify({ status: statusOf(result), statusMessage: result.structuredContent?.statusMessage }); -const brief = r => - JSON.stringify({ - status: r.structuredContent?.status, - statusMessage: r.structuredContent?.statusMessage, - ...(r.structuredContent?.result ? { result: r.structuredContent.result } : {}), - }); - -console.log(`== tools/list (${ENDPOINT}) ==`); -const names = (await rpc("tools/list")).tools.map(t => t.name); -console.log(names.join(", ")); -for (const expected of ["generate_report", "task_status", "task_cancel"]) { - if (!names.includes(expected)) throw new Error(`missing tool ${expected}`); +/** Polls task_status as `user` until the task is final, or `tries` polls have passed. */ +async function poll(taskId, user = ALICE, tries = 30) { + let last; + for (let i = 0; i < tries; i++) { + await sleep(1500); + last = await call("task_status", { taskId }, user); + console.log(" poll", brief(last)); + if (TERMINAL.has(statusOf(last))) break; + } + return last; } -console.log("\n== 1. happy path =="); -const created = await call("generate_report", { topic: "coffee trends" }); -console.log("created", JSON.stringify(created)); -const taskId = created.structuredContent?.taskId; -if (!taskId) throw new Error("expected a task handle"); - -let last; -for (let i = 0; i < 20; i++) { - await sleep(1500); - last = await call("task_status", { taskId }); - console.log("poll ", brief(last)); - if (["completed", "failed", "cancelled"].includes(last.structuredContent?.status)) break; -} -if (last.structuredContent?.status !== "completed") { - throw new Error(`expected completed, got ${last.structuredContent?.status}`); +/** Waits until `ready()` resolves true, checking once a second. */ +async function until(ready, what, tries = 25) { + for (let i = 0; i < tries; i++) { + if (await ready()) return; + await sleep(1000); + } + throw new Error(`timed out waiting for ${what}`); } -console.log("\n== 2. cancel mid-flight =="); -const second = await call("generate_report", { topic: "tea rituals" }); -const secondId = second.structuredContent.taskId; -console.log("created", secondId); -await sleep(3000); -console.log("cancel", brief(await call("task_cancel", { taskId: secondId }))); -await sleep(6000); -const afterCancel = await call("task_status", { taskId: secondId }); -console.log("after ", brief(afterCancel)); -if (afterCancel.structuredContent?.status !== "cancelled") { - throw new Error(`expected cancelled, got ${afterCancel.structuredContent?.status}`); +let passed = 0; +async function test(name, body) { + console.log(`\n== ${name} ==`); + await body(); + passed++; } -if (afterCancel.structuredContent?.result) throw new Error("a cancelled task must not carry a result"); -console.log("\n== 3. unknown task =="); -const unknown = await call("task_status", { taskId: "does-not-exist" }); -console.log("unknown", JSON.stringify(unknown)); -if (!unknown.isError) throw new Error("expected a tool error"); +// --------------------------------------------------------------------------------------------- +// Tasks +// --------------------------------------------------------------------------------------------- + +let hasFailTool = false; + +await test(`tools/list (${ENDPOINT})`, async () => { + const names = (await rpc("tools/list")).tools.map((tool) => tool.name); + console.log(" ", names.join(", ")); + for (const expected of ["generate_report", "task_status", "task_cancel"]) { + check(names.includes(expected), `missing tool ${expected}`); + } + hasFailTool = names.includes("always_fail"); +}); + +await test("a token the server doesn't accept is refused", async () => { + const response = await post(ENDPOINT, "tools/list", {}, "not-a-demo-user"); + console.log(" HTTP", response.status); + check(response.status === 401, `expected 401, got ${response.status}`); +}); + +await test("happy path, and another user can neither see nor cancel the task", async () => { + const created = await call("generate_report", { topic: "coffee trends" }); + const taskId = created.structuredContent?.taskId; + check(taskId && statusOf(created) === "working", `expected a working task, got ${brief(created)}`); + console.log(" alice started", taskId); + + const peek = await call("task_status", { taskId }, BOB); + check(isUnknown(peek), `bob must see alice's task as unknown, got ${JSON.stringify(peek)}`); + check(peek.structuredContent === undefined, "an unknown task must leak no structuredContent"); + const steal = await call("task_cancel", { taskId }, BOB); + check(isUnknown(steal), `bob must not cancel alice's task, got ${JSON.stringify(steal)}`); + console.log(" bob: unknown task, for both task_status and task_cancel"); + + const last = await poll(taskId); + check(statusOf(last) === "completed", `expected completed, got ${statusOf(last)}`); + check(last.content.length >= 2, "the completed status must carry the handler's own content"); +}); + +await test("cancel mid-flight", async () => { + const created = await call("generate_report", { topic: "tea rituals" }); + const taskId = created.structuredContent.taskId; + await sleep(3000); + console.log(" cancel", brief(await call("task_cancel", { taskId }))); + await sleep(6000); + const after = await call("task_status", { taskId }); + console.log(" after ", brief(after)); + check(statusOf(after) === "cancelled", `expected cancelled, got ${statusOf(after)}`); + check(!after.structuredContent?.result, "a cancelled task must not carry a result"); +}); + +await test("an unknown task id", async () => { + const unknown = await call("task_status", { taskId: randomUUID() }); + check(isUnknown(unknown), `expected an unknown-task error, got ${JSON.stringify(unknown)}`); + const malformed = await call("task_status", { taskId: "does-not-exist" }); + check(malformed.isError === true, "a malformed id must be a tool error"); +}); + +await test("a task that always throws ends failed", async () => { + if (!hasFailTool) { + console.log(" skipped: start the app with MCP_TOOLKIT_E2E=1 (pnpm e2e does)"); + return; + } + const created = await call("always_fail", {}); + const last = await poll(created.structuredContent.taskId); + check(statusOf(last) === "failed", `expected failed, got ${statusOf(last)}`); + check(last.structuredContent?.error?.message, "a failed task must carry an error"); + console.log(" error", last.structuredContent.error.message); +}); + +await test("unsigned and forged deliveries are refused", async () => { + const routes = [ + [EXECUTE, { taskId: randomUUID() }], + [`${WATCH}/events`, { subscriptionId: "sub_forged", envelope: {} }], + ]; + for (const [path, body] of routes) { + for (const signature of [undefined, "eyJhbGciOiJIUzI1NiJ9.e30.forged"]) { + const response = await fetch(`${BASE}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(signature ? { "upstash-signature": signature } : {}), + }, + body: JSON.stringify(body), + }); + await response.body?.cancel(); + console.log(` ${path} ${signature ? "forged" : "unsigned"}: HTTP ${response.status}`); + check(!response.ok, `${path} accepted a delivery that QStash didn't sign`); + } + } +}); + +// --------------------------------------------------------------------------------------------- +// Events (Deploy Watch). Only on the default run: the events server doesn't change with MCP_PATH. +// --------------------------------------------------------------------------------------------- if (ENDPOINT === "/api/mcp") { - // Deploy Watch: the events server. No tasks involved. - const WATCH = "/api/deploy-watch"; - console.log("\n== 4. Deploy Watch: deploy.finished =="); - const { randomBytes } = await import("node:crypto"); - const listed = (await rpc("events/list", {}, WATCH)).events.map(e => e.name); - console.log("events", listed.join(", ")); - if (!listed.includes("deploy.finished")) throw new Error("missing deploy.finished event"); - - const receiverId = randomBytes(6).toString("hex"); - const receiverUrl = `${BASE}/api/receiver?id=${receiverId}`; - const secret = `whsec_${randomBytes(32).toString("base64")}`; - await fetch(receiverUrl, { method: "PUT", body: JSON.stringify({ secret }) }); - // Only production deploys: the staging one below must not arrive. - const subscription = await rpc( - "events/subscribe", - { - name: "deploy.finished", - arguments: { environment: "production" }, - delivery: { mode: "webhook", url: receiverUrl, secret }, - ttlMs: 600_000, - }, - WATCH, - ); - console.log("subscribed", subscription.id); - - const report = async body => - (await fetch(`${BASE}${WATCH}/deploys`, { method: "POST", body: JSON.stringify(body) })).json(); - const staging = await report({ environment: "staging", commit: "staging only" }); - const production = await report({ environment: "production", status: "failed" }); - console.log("reported", staging.deploy.id, "(staging)", production.deploy.id, "(production)"); - - let received = []; - for (let i = 0; i < 20; i++) { - await sleep(1000); - received = (await (await fetch(receiverUrl)).json()).filter(r => r.body.name === "deploy.finished"); - if (received.length) break; + const redis = Redis.fromEnv(); + const subscriptionKey = (subscriptionId) => `deploy-watch:events:sub:${subscriptionId}`; + + /** A fresh webhook receiver on the demo's /api/receiver, with its own signing secret. */ + async function receiver() { + const url = `${BASE}/api/receiver?id=${randomBytes(6).toString("hex")}`; + const secret = `whsec_${randomBytes(32).toString("base64")}`; + await fetch(url, { method: "PUT", body: JSON.stringify({ secret }) }); + return { + url, + secret, + received: async () => + (await (await fetch(url)).json()).filter((r) => r.body.name === "deploy.finished"), + forget: () => fetch(url, { method: "DELETE" }), + }; } - await sleep(2000); - received = (await (await fetch(receiverUrl)).json()).filter(r => r.body.name === "deploy.finished"); - console.log("delivered", JSON.stringify(received.map(r => ({ valid: r.valid, id: r.body.data.id, env: r.body.data.environment })))); - if (received.length !== 1) throw new Error(`expected exactly one delivery, got ${received.length}`); - if (!received[0].valid) throw new Error("delivery signature did not verify"); - if (received[0].body.data.id !== production.deploy.id) throw new Error("wrong deploy delivered"); - - const recent = await rpc("tools/call", { name: "list_recent_deploys", arguments: { limit: 2 } }, WATCH); - console.log("recent", recent.content[0].text.split("\n").join(" | ")); - - await rpc( - "events/unsubscribe", - { name: "deploy.finished", arguments: { environment: "production" }, delivery: { mode: "webhook", url: receiverUrl } }, - WATCH, - ); + + const subscribe = (user, args, to) => + rpc( + "events/subscribe", + { + name: "deploy.finished", + arguments: args, + delivery: { mode: "webhook", url: to.url, secret: to.secret }, + ttlMs: 600_000, + }, + { path: WATCH, user }, + ); + const unsubscribe = (user, args, to) => + rpc( + "events/unsubscribe", + { name: "deploy.finished", arguments: args, delivery: { mode: "webhook", url: to.url } }, + { path: WATCH, user }, + ); + const report = async (deploy) => + (await (await fetch(`${BASE}${WATCH}/deploys`, { method: "POST", body: JSON.stringify(deploy) })).json()) + .deploy; + + await test("Deploy Watch: filtering, per-user subscriptions, authorize at delivery", async () => { + const listed = (await rpc("events/list", {}, { path: WATCH })).events.map((e) => e.name); + check(listed.includes("deploy.finished"), "missing deploy.finished event"); + + const ops = await receiver(); + const alices = await subscribe(ALICE, { environment: "production" }, ops); + console.log(" alice subscribed to production:", alices.id); + + // The same unsubscribe from another user: the id includes the subscriber, so nothing changes. + await unsubscribe(BOB, { environment: "production" }, ops); + check(await redis.exists(subscriptionKey(alices.id)), "bob removed alice's subscription"); + console.log(" bob's unsubscribe left it alone"); + + // authorize: the intern can't ask for production, but may subscribe to everything… + const intern = await receiver(); + const refused = await subscribe(INTERN, { environment: "production" }, intern).then( + () => undefined, + (error) => error.rpc, + ); + check(refused?.data?.reason === "not_authorized", `expected not_authorized, got ${JSON.stringify(refused)}`); + await subscribe(INTERN, {}, intern); + console.log(" intern: refused for production, subscribed to everything"); + + const staging = await report({ environment: "staging", commit: "staging only" }); + const production = await report({ environment: "production", status: "failed" }); + console.log(" reported", staging.id, "(staging)", production.id, "(production)"); + + await until( + async () => (await ops.received()).length > 0 && (await intern.received()).length > 0, + "both deliveries", + ); + await sleep(3000); // anything that shouldn't arrive has had time to + + const toOps = await ops.received(); + const toIntern = await intern.received(); + const summary = (list) => list.map((r) => `${r.body.data.environment}${r.valid ? "" : " (bad signature)"}`); + console.log(" alice got", summary(toOps), "· intern got", summary(toIntern)); + check(toOps.length === 1 && toOps[0].body.data.id === production.id, "alice must get only the production deploy"); + check(toOps[0].valid, "alice's delivery signature did not verify"); + // …and each delivery is authorized against the deploy itself, so production never reaches them. + check(toIntern.length === 1 && toIntern[0].body.data.id === staging.id, "the intern must get only staging"); + check(toIntern[0].valid, "the intern's delivery signature did not verify"); + + const recent = await rpc("tools/call", { name: "list_recent_deploys", arguments: { limit: 2 } }, { path: WATCH }); + check(textOf(recent).includes("staging only"), "list_recent_deploys missed the staging deploy"); + + await unsubscribe(ALICE, { environment: "production" }, ops); + await unsubscribe(INTERN, {}, intern); + check(!(await redis.exists(subscriptionKey(alices.id))), "unsubscribe left the subscription behind"); + }); + + await test("Deploy Watch: a 410 from the host deletes the subscription", async () => { + const gone = await receiver(); + const { id: subscriptionId } = await subscribe(ALICE, { environment: "staging" }, gone); + check(await redis.exists(subscriptionKey(subscriptionId)), "subscription not stored"); + await gone.forget(); // its next POST answers 410 + await report({ environment: "staging", commit: "to a receiver that is gone" }); + await until(async () => !(await redis.exists(subscriptionKey(subscriptionId))), "the subscription to be deleted"); + console.log(" deleted", subscriptionId); + }); } -console.log("\nALL E2E CHECKS PASSED"); +console.log(`\nALL ${passed} E2E CHECKS PASSED (${ENDPOINT})`); From e14b0bfd30d6e63bd21f4f64be1b087d7fedca5d Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 23:55:38 +0300 Subject: [PATCH 32/33] fix: add ci step --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1027515..10145a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,24 @@ jobs: fi pnpm e2e + - name: E2E (MCP toolkit demo, local QStash, no model) + # Starts the local QStash dev server and the demo built above, and drives it with raw JSON-RPC + # as a model and a host would (no model, no QStash account). Against both task servers: run to + # completion, cancel, another user's task reads as unknown, a throwing task ends `failed`, + # unsigned or forged deliveries are refused. Against Deploy Watch: filtered webhooks with + # valid signatures, per-user subscriptions, `authorize` checked at delivery, 410 deletes the + # subscription. Skipped without Redis secrets (e.g. fork PRs). + working-directory: examples/mcp-toolkit-demo + env: + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + run: | + if [ -z "$UPSTASH_REDIS_REST_URL" ]; then + echo "No Redis secrets available — skipping the MCP toolkit e2e suite." + exit 0 + fi + pnpm e2e + - name: E2E eval (eve memory slots, mocked model) # Boots the eve demo's agent with a scripted mockModel (no model provider, no # OPENAI_API_KEY) and asserts both Upstash Redis memory integrations run at eve's real From fd8ee97b8f57711be2bd235fc2919e76f277ec11 Mon Sep 17 00:00:00 2001 From: Upstash Box Date: Thu, 8 Oct 2026 21:02:30 +0000 Subject: [PATCH 33/33] DX-3022: mcp-toolkit: task authorize + principal, subscription limit, review fixes - Tasks: `task.principal` (the owner) in TaskContext; optional `authorize(args, { principal, auth, request })` on tasks.define, run before the task is stored or queued - Tasks: reject non-positive / non-integer `defaults.ttlMs` and `pollIntervalMs` - Events: `maxSubscriptions` (default 8, Infinity = off) live subscriptions per subscriber, checked before the callback challenge and atomically in the store (Lua over idx: and by:); SubscriptionStore gains count(), put() takes { limit } and returns boolean, delete() takes subscriber - Events: route and authorize deliveries by the payload's input fields parsed with `input`, so transforms apply on both sides; a payload that doesn't fit `input` makes emit throw - Drop QStash deduplicationId for tasks and events (Copilot: the sanitized event id was lossy); the event id still reaches the host as webhook-id - @upstash/redis floor ^1.38.4; aria-pressed on the demo's transport toggle Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n --- .changeset/mcp-toolkit-initial.md | 9 +- CLAUDE.md | 19 ++-- examples/mcp-toolkit-demo/app/page.tsx | 1 + packages/mcp-toolkit/README.md | 67 +++++++++++---- packages/mcp-toolkit/package.json | 2 +- .../src/events/backends/qstash.test.ts | 53 +++++++++--- .../mcp-toolkit/src/events/backends/qstash.ts | 80 +++++++++++++---- packages/mcp-toolkit/src/events/core.test.ts | 85 +++++++++++++++++- packages/mcp-toolkit/src/events/core.ts | 86 +++++++++++++++---- packages/mcp-toolkit/src/events/types.ts | 12 ++- .../src/tasks/backends/qstash.test.ts | 5 +- .../mcp-toolkit/src/tasks/backends/qstash.ts | 1 - packages/mcp-toolkit/src/tasks/core.test.ts | 61 +++++++++++++ packages/mcp-toolkit/src/tasks/core.ts | 46 +++++++++- packages/mcp-toolkit/src/tasks/index.ts | 1 + packages/mcp-toolkit/src/tasks/types.ts | 7 +- packages/mcp-toolkit/src/test-support.ts | 11 ++- pnpm-lock.yaml | 2 +- 18 files changed, 462 insertions(+), 86 deletions(-) diff --git a/.changeset/mcp-toolkit-initial.md b/.changeset/mcp-toolkit-initial.md index 9c37727..a9095e5 100644 --- a/.changeset/mcp-toolkit-initial.md +++ b/.changeset/mcp-toolkit-initial.md @@ -11,14 +11,17 @@ lives in a `TaskStore` and the work runs behind a `TaskDispatcher`, and the disp delivery endpoint (`export const POST = tasks.createExecuteHandler()`) and always verifies QStash's signature against the URL it published to. Tasks are declared once at module scope with `tasks.define(...)` and attached to each request's server with `tasks.register(server)`. A required -`principal` scopes tasks to their caller (and subscriptions to theirs). +`principal` scopes tasks to their caller (and subscriptions to theirs); handlers get it as +`task.principal`, and an optional `authorize` on `tasks.define` checks the arguments before a task +is stored or queued. `@upstash/mcp-toolkit/events` — MCP Events with webhook delivery, as ChatGPT ships it. Typed `events.define(...)` handles with `emit(payload)`: the payload's values route the event and are what the required `authorize` is checked against, at subscribe and again before every delivery. `events/list`, `events/subscribe` and `events/unsubscribe` are registered on your server, with a -signed verification challenge, deterministic subscription ids, expiry and refresh, SSRF checks on -callback URLs, and signing secrets encrypted at rest. `QStashDelivery` signs each attempt with +signed verification challenge, deterministic subscription ids, expiry and refresh, a configurable +limit of live subscriptions per subscriber (8 by default), SSRF checks on callback URLs, and signing +secrets encrypted at rest. `QStashDelivery` signs each attempt with Standard Webhooks and lets QStash retry failures (`export const POST = events.createDeliveryHandler()`). The package uses WebCrypto only, so it runs on Node and edge runtimes. diff --git a/CLAUDE.md b/CLAUDE.md index f5f224b..2b07143 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -762,11 +762,10 @@ implements TanStack AI's own backend contracts (see its section below) — keep `dispatcher.cancel(taskId)` when the returned status is `cancelled` — `cancel` must be idempotent. There is no `dispatchId`: `QStashDispatcher.cancel` is a no-op (a redelivery of a cancelled task finds it settled and does nothing), Workflow's run id is the task id. -- **Dispatch dedupe on the task id:** QStash `deduplicationId` and Workflow `workflowRunId` are the - task id. That is only safe because ids - are random: if keyed (deterministic) ids ever come back, dedupe must move to a per-record key - (taskId + createdAt), since QStash keeps dedup ids for 10 minutes and Workflow refuses a reused - run id. A failed `dispatch` settles the fresh record `failed` ("Could not be queued") and rethrows. +- **No QStash `deduplicationId`** (tasks or events): the layer dispatches each task once, and an + event's id reaches the host as `webhook-id`, which is where Standard Webhooks dedupes. Workflow's + `workflowRunId` is the task id; that is only safe because ids are random (Workflow refuses a + reused run id). A failed `dispatch` settles the fresh record `failed` ("Could not be queued") and rethrows. - **Workflow context is inferred** from the dispatcher (`TaskDispatcher`), so `createTaskLayer({ dispatcher: new WorkflowDispatcher(...) })` types `task.run` / `task.sleep` with no type argument; a test in `workflow.test.ts` keeps it that way. @@ -873,8 +872,14 @@ implements TanStack AI's own backend contracts (see its section below) — keep **every IP literal** (v4 after the URL parser's normalization, and any `[…]` v6) plus localhost/single-label/`.local`/`.internal`; the old private-range/NAT64/6to4 parser is gone. It does not resolve DNS. Bad params are `-32602` with a `reason`. - **QStash `deduplicationId` cannot contain `:`** (dev server answers 400) — ids are - `${eventId}_${subscriptionId}` sanitized; this was caught only by the e2e smoke. + **Subscription limit:** `maxSubscriptions` (default 8, `Infinity` = off) live subscriptions per + subscriber across all events. The layer checks `store.count` before the challenge (no outbound + POST for a caller at the limit), and `put(sub, { limit })` re-checks atomically (Redis: one Lua + script over `idx:` and `by:`). Refreshes never count. Routing and delivery-time + `authorize` use the payload's input fields *parsed with `input`* (`projection`), so transforms + match on both sides; a payload that doesn't fit `input` makes `emit` throw. + QStash `deduplicationId` cannot contain `:` (dev server answers 400), one more reason not to + derive it from caller-chosen event ids. - **Local dev needs the QStash dev server** (`npx @upstash/qstash-cli dev`) — it prints deterministic creds. `APP_URL` must be reachable *from QStash*. diff --git a/examples/mcp-toolkit-demo/app/page.tsx b/examples/mcp-toolkit-demo/app/page.tsx index 4fcd377..6134a1b 100644 --- a/examples/mcp-toolkit-demo/app/page.tsx +++ b/examples/mcp-toolkit-demo/app/page.tsx @@ -161,6 +161,7 @@ export default function Page() { key={key} type="button" className={`driver ${server === key ? "on" : ""}`} + aria-pressed={server === key} onClick={() => setServer(key)} > {SERVERS[key].label} diff --git a/packages/mcp-toolkit/README.md b/packages/mcp-toolkit/README.md index 2078c05..0dcf8c4 100644 --- a/packages/mcp-toolkit/README.md +++ b/packages/mcp-toolkit/README.md @@ -121,6 +121,27 @@ progress, then the handler's result) and `task_cancel`. To stop a cancelled task `await task.isCancelled()` between steps. Cancelling is cooperative, so running code only stops where it checks. +**Check what the arguments point at.** The model fills in tool arguments, so a `workspaceId` in +them is whatever it was told. Give the task an `authorize`, which runs before the task is stored or +queued, and use `task.principal` (the user who started the task) inside the handler. Never take a +user id from the arguments: + +```ts +tasks.define( + "export_workspace", + { + description: "Exports a workspace to CSV.", + inputSchema: z.object({ workspaceId: z.string() }), + // May this user export this workspace? `false` refuses the call. + authorize: ({ workspaceId }, { principal }) => canExport(principal, workspaceId), + }, + async ({ workspaceId }, task) => { + const csv = await exportWorkspace(workspaceId, { as: task.principal }); + return { content: [{ type: "text", text: csv }] }; + }, +); +``` +
What the model sees @@ -166,9 +187,13 @@ export const tasks = createTaskLayer({ tasks.define( "migrate_workspace", - { description: "Copies a workspace to new storage.", inputSchema: z.object({ workspaceId: z.string() }) }, + { + description: "Copies a workspace to new storage.", + inputSchema: z.object({ workspaceId: z.string() }), + authorize: ({ workspaceId }, { principal }) => isOwner(principal, workspaceId), + }, async ({ workspaceId }, task) => { - const batches = await task.run("plan", () => listBatches(workspaceId)); + const batches = await task.run("plan", () => listBatches(workspaceId, task.principal)); for (const [i, batch] of batches.entries()) { if (await task.isCancelled()) return {}; await task.update(`Copying batch ${i + 1}/${batches.length}`); @@ -278,8 +303,10 @@ the event id stays the same across retries so the host can drop duplicates. Matching and authorize Every `input` field must also be a `payload` field: the payload is the one place an event's values -come from, and the same values are used to route it and to authorize it. A subscription matches -when each argument it gave equals the payload's value. Emitting +come from, and the same values are used to route it and to authorize it. The payload's values for +the input fields are parsed with the `input` schema, so its transforms apply on both sides and +`authorize` gets the types it declares. A subscription matches when each argument it gave equals +the payload's value. Emitting `{ documentId: "doc_123", text }` reaches subscribers of `{ documentId: "doc_123" }` and of `{}`. `authorize(args, caller)` runs twice: @@ -294,8 +321,8 @@ when each argument it gave equals the payload's value. Emitting `() => true` lets every authenticated subscriber hear every matching event. -Pass `{ eventId }` as the second argument to `emit` to deduplicate: emitting the same id twice -delivers once. +Pass `{ eventId }` as the second argument to `emit` to give an event a stable id. It is sent as +`webhook-id`, so a host drops a second emit with the same id as a duplicate.
@@ -318,6 +345,11 @@ delivers once. random bytes. A refresh with the same secret skips the challenge. If you rotate the key, stored subscriptions stop receiving events until the host refreshes them. - **Lifetime.** A subscription lasts 7 days by default and 30 days at most. +- **How many.** A subscriber holds at most 8 live subscriptions across all events (set + `maxSubscriptions`; `Infinity` turns it off). Past that, a new subscribe is refused before the + callback is challenged, with reason `subscription_limit`; refreshing an existing one still works. + Each subscription is a webhook per matching emit, so this bounds what one user can make your + server send. - **Host answers.** `410` deletes the subscription, `413` and redirects drop the event, and any other error is retried. - **Not implemented:** the draft's poll and stream delivery modes (`events/subscribe` refuses @@ -341,7 +373,9 @@ subscribe yet. The spec draft is
Who can see what -**Tasks.** The server sets the owner from `principal`, and no tool argument can set it. +**Tasks.** The server sets the owner from `principal`, and no tool argument can set it. Whether a +caller may start a task with given arguments is your `authorize`; the handler gets the owner as +`task.principal`. `task_status` and `task_cancel` only accept UUID task ids, and compare the stored owner with the caller. For anyone else, the task looks exactly like an unknown id, so they can't even tell it exists. Task ids are random UUIDs. No tool lists tasks, and the execute route only accepts signed @@ -375,8 +409,9 @@ Treat the Redis credentials like any other production secret. - A Redis client built with `automaticDeserialization: false` is not supported. **`mcp-events:sub:`**: the subscription (`event`, `args`, `url`, `encryptedSecret`, -`subscriber`, `createdAt`, `expiresAt`), expiring with it. **`mcp-events:idx:`**: a sorted -set of the event's subscription ids, scored by expiry. +`subscriber`, `createdAt`, `expiresAt`), expiring with it. **`mcp-events:idx:`** and +**`mcp-events:by:`**: sorted sets of the event's and the subscriber's subscription ids, +scored by expiry. The second one enforces the limit. **Not in Redis.** A task message in QStash carries only `{ taskId }`. An event message carries the full payload, which stays in QStash (and in its DLQ, if every retry fails) until it is delivered. @@ -389,13 +424,13 @@ The toolkit never stores the caller's token, the request, the plaintext webhook All options **`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: -`defaults.ttlMs` (1 day) and `defaults.pollIntervalMs` (2s). +`defaults.ttlMs` (1 day) and `defaults.pollIntervalMs` (2s), in positive whole milliseconds. **`tasks.define(name, config, handler)`**: `description` and `inputSchema` are required. Optional: -`title`, `completedMessage`. +`title`, `completedMessage`, `authorize(args, { principal, auth, request })`. **`createEventLayer`**: `store`, `delivery` and `principal` are required, and so is `secretKey` -unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `allowInsecureCallbacks`. +unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `maxSubscriptions` (8), `allowInsecureCallbacks`. **`events.define(name, config)`**: `description`, `payload` and `authorize` are required. Optional: `title`, `input`. @@ -436,15 +471,17 @@ interface TaskStore { } interface TaskDispatcher { - dispatch(task: Task): Promise; // idempotent per task id + dispatch(task: Task): Promise; // called once per task cancel(taskId: string): Promise; createExecuteHandler(endpoints: TaskEndpoints): (request: Request) => Promise; } interface SubscriptionStore { - put(subscription: Subscription): Promise; + // false, storing nothing, when a new one would put its subscriber over `limit` (atomically) + put(subscription: Subscription, options: { limit: number }): Promise; get(id: string): Promise; - delete(subscription: { id: string; event: string }): Promise; + count(subscriber: string): Promise; // live subscriptions, across all events + delete(subscription: { id: string; event: string; subscriber: string }): Promise; find(event: string): Promise; // every live subscription to the event } diff --git a/packages/mcp-toolkit/package.json b/packages/mcp-toolkit/package.json index 00f5537..999f935 100644 --- a/packages/mcp-toolkit/package.json +++ b/packages/mcp-toolkit/package.json @@ -53,7 +53,7 @@ ], "dependencies": { "@upstash/qstash": "^2.11.3", - "@upstash/redis": "^1.38.0", + "@upstash/redis": "^1.38.4", "@upstash/workflow": "^1.3.3", "zod": "^4.2.0" }, diff --git a/packages/mcp-toolkit/src/events/backends/qstash.test.ts b/packages/mcp-toolkit/src/events/backends/qstash.test.ts index c4d8c18..21e4726 100644 --- a/packages/mcp-toolkit/src/events/backends/qstash.test.ts +++ b/packages/mcp-toolkit/src/events/backends/qstash.test.ts @@ -35,14 +35,14 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { it("round-trips a subscription, keeping a numeric-looking subscriber a string", async () => { const sub = makeSub(); - await store.put(sub); + await store.put(sub, { limit: Infinity }); expect(await store.get(sub.id)).toEqual(sub); expect(await store.get("sub_missing")).toBeNull(); }); it("expires the record with the subscription", async () => { const sub = makeSub({ expiresAt: Date.now() + 30_000 }); - await store.put(sub); + await store.put(sub, { limit: Infinity }); const pttl = await redis.pttl(subKey(sub.id)); expect(pttl).toBeGreaterThan(0); expect(pttl).toBeLessThanOrEqual(30_000); @@ -53,7 +53,7 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { const one = makeSub({ event: "find", args: { repo: "a" } }); const elsewhere = makeSub({ event: "other", args: {} }); const expired = makeSub({ event: "find" }); - for (const sub of [all, one, elsewhere]) await store.put(sub); + for (const sub of [all, one, elsewhere]) await store.put(sub, { limit: Infinity }); // Written live, then left in the index past its expiry. await redis.zadd(indexKey("find"), { score: Date.now() - 1, member: expired.id }); @@ -68,7 +68,7 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { makeSub({ event: "popular", args: {}, subscriber: `member-${i}` }), ); for (let i = 0; i < subs.length; i += 100) { - await Promise.all(subs.slice(i, i + 100).map((sub) => store.put(sub))); + await Promise.all(subs.slice(i, i + 100).map((sub) => store.put(sub, { limit: Infinity }))); } const found = await store.find("popular"); expect(found).toHaveLength(1100); @@ -77,21 +77,54 @@ describe.skipIf(!hasRedisCreds)("RedisSubscriptionStore (real Redis)", () => { it("refreshing replaces the record and keeps one index entry", async () => { const sub = makeSub({ event: "refresh", expiresAt: Date.now() + 10_000 }); - await store.put(sub); - await store.put({ ...sub, expiresAt: Date.now() + 50_000 }); + await store.put(sub, { limit: Infinity }); + await store.put({ ...sub, expiresAt: Date.now() + 50_000 }, { limit: Infinity }); expect((await store.get(sub.id))?.expiresAt).toBeGreaterThan(Date.now() + 40_000); expect(await store.find("refresh")).toHaveLength(1); }); it("prunes expired index entries on the next write", async () => { await redis.zadd(indexKey("prune"), { score: Date.now() - 1000, member: "sub_old" }); - await store.put(makeSub({ event: "prune" })); + await store.put(makeSub({ event: "prune" }), { limit: Infinity }); expect(await redis.zscore(indexKey("prune"), "sub_old")).toBeNull(); }); + it("refuses a new subscription past the subscriber's limit, atomically, but allows a refresh", async () => { + const subscriber = `limited-${Date.now()}`; + const [a, b, c] = [1, 2, 3].map((i) => makeSub({ event: `limit-${i}`, subscriber })); + expect(await store.put(a!, { limit: 2 })).toBe(true); + expect(await store.put(b!, { limit: 2 })).toBe(true); + expect(await store.count(subscriber)).toBe(2); + expect(await store.put(c!, { limit: 2 })).toBe(false); + expect(await store.get(c!.id)).toBeNull(); + expect(await store.find("limit-3")).toEqual([]); + // Replacing one of theirs is not a new subscription. + expect(await store.put({ ...a!, expiresAt: Date.now() + 90_000 }, { limit: 2 })).toBe(true); + // Concurrent puts can't overshoot: the check and the write are one script. + const racers = Array.from({ length: 5 }, (_, i) => + makeSub({ event: `race-${i}`, subscriber: `${subscriber}-race` }), + ); + const results = await Promise.all(racers.map((sub) => store.put(sub, { limit: 2 }))); + expect(results.filter(Boolean)).toHaveLength(2); + expect(await store.count(`${subscriber}-race`)).toBe(2); + }); + + it("frees a slot on delete and when a subscription expires", async () => { + const subscriber = `freed-${Date.now()}`; + const a = makeSub({ event: "freed", subscriber }); + await store.put(a, { limit: 1 }); + await store.delete(a); + expect(await store.count(subscriber)).toBe(0); + const b = makeSub({ event: "freed", subscriber, expiresAt: Date.now() + 1_500 }); + expect(await store.put(b, { limit: 1 })).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 1_700)); + expect(await store.count(subscriber)).toBe(0); + expect(await store.put(makeSub({ event: "freed", subscriber }), { limit: 1 })).toBe(true); + }); + it("deletes the record and its index entry", async () => { const sub = makeSub({ event: "delete" }); - await store.put(sub); + await store.put(sub, { limit: Infinity }); await store.delete(sub); expect(await store.get(sub.id)).toBeNull(); expect(await store.find("delete")).toEqual([]); @@ -171,7 +204,7 @@ describe("QStashDelivery", () => { expect(sent).toEqual([]); }); - it("publishes one deduplicated message per subscription, in batches of 100", async () => { + it("publishes one message per subscription, in batches of 100", async () => { const batches: Record[][] = []; const qstash = { batchJSON: async (messages: Record[]) => { @@ -187,7 +220,7 @@ describe("QStashDelivery", () => { url: URL, body: jobs[0], retries: 3, - deduplicationId: "evt_1_sub_0", }); + expect(batches[0]?.[0]).not.toHaveProperty("deduplicationId"); }); }); diff --git a/packages/mcp-toolkit/src/events/backends/qstash.ts b/packages/mcp-toolkit/src/events/backends/qstash.ts index f193e05..874325b 100644 --- a/packages/mcp-toolkit/src/events/backends/qstash.ts +++ b/packages/mcp-toolkit/src/events/backends/qstash.ts @@ -35,8 +35,31 @@ export type RedisSubscriptionStoreConfig = { }; /** - * One key per subscription, expiring with it, plus one sorted set per event scored by expiry. An - * emit reads the event's live subscriptions, and the layer keeps those whose arguments match. + * Stores a subscription unless that would put its subscriber over the limit. Keys: the record, the + * event's index, the subscriber's index. Args: record JSON, ttl ms, expiresAt, now, id, limit (-1: + * none). Both indexes are sorted sets scored by expiry; expired entries are pruned first, so only + * live subscriptions count. Returns 1 when stored, 0 when refused. + */ +const PUT_SCRIPT = `#!lua flags=allow-key-locking +redis.call('ZREMRANGEBYSCORE', KEYS[3], '-inf', ARGV[4]) +local limit = tonumber(ARGV[6]) +if limit >= 0 and not redis.call('ZSCORE', KEYS[3], ARGV[5]) + and redis.call('ZCARD', KEYS[3]) >= limit then + return 0 +end +redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) +redis.call('ZADD', KEYS[2], ARGV[3], ARGV[5]) +redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', ARGV[4]) +redis.call('ZADD', KEYS[3], ARGV[3], ARGV[5]) +local last = redis.call('ZRANGE', KEYS[3], -1, -1, 'WITHSCORES') +redis.call('PEXPIREAT', KEYS[3], last[2]) +return 1 +`; + +/** + * One key per subscription, expiring with it, plus a sorted set per event and one per subscriber, + * scored by expiry. An emit reads the event's live subscriptions, and the layer keeps those whose + * arguments match; the subscriber's set enforces the per-subscriber limit. */ export class RedisSubscriptionStore implements SubscriptionStore { private readonly prefix: string; @@ -49,25 +72,48 @@ export class RedisSubscriptionStore implements SubscriptionStore { ); } - async put(subscription: Subscription): Promise { + async put(subscription: Subscription, options: { limit: number }): Promise { const now = Date.now(); const ttl = subscription.expiresAt - now; - if (ttl <= 0) return; - const index = this.indexKey(subscription.event); - await this.redis() - .multi() - .set(this.subKey(subscription.id), JSON.stringify(subscription), { px: ttl }) - .zadd(index, { score: subscription.expiresAt, member: subscription.id }) - .zremrangebyscore(index, "-inf", now) - .exec(); + if (ttl <= 0) return true; // already expired: nothing to keep, and not a limit problem + const stored = await this.redis().eval( + PUT_SCRIPT, + [ + this.subKey(subscription.id), + this.indexKey(subscription.event), + this.subscriberKey(subscription.subscriber), + ], + [ + JSON.stringify(subscription), + String(ttl), + String(subscription.expiresAt), + String(now), + subscription.id, + String(Number.isFinite(options.limit) ? options.limit : -1), + ], + ); + return Number(stored) === 1; } async get(id: string): Promise { return parse(await this.redis().get(this.subKey(id))); } - async delete({ id, event }: Pick): Promise { - await this.redis().multi().del(this.subKey(id)).zrem(this.indexKey(event), id).exec(); + async count(subscriber: string): Promise { + return await this.redis().zcount(this.subscriberKey(subscriber), Date.now(), "+inf"); + } + + async delete({ + id, + event, + subscriber, + }: Pick): Promise { + await this.redis() + .multi() + .del(this.subKey(id)) + .zrem(this.indexKey(event), id) + .zrem(this.subscriberKey(subscriber), id) + .exec(); } async find(event: string): Promise { @@ -92,6 +138,10 @@ export class RedisSubscriptionStore implements SubscriptionStore { private indexKey(event: string): string { return `${this.prefix}idx:${event}`; } + + private subscriberKey(subscriber: string): string { + return `${this.prefix}by:${subscriber}`; + } } function parse(raw: unknown): Subscription | null { @@ -115,7 +165,7 @@ export type QStashDeliveryConfig = { enableTelemetry?: boolean; }; -/** Every webhook delivery as a QStash message: durable, deduplicated, retried with backoff. */ +/** Every webhook delivery as a QStash message: durable and retried with backoff. */ export class QStashDelivery implements EventDelivery { private readonly config: QStashDeliveryConfig; private readonly qstash: () => QStashClient; @@ -136,8 +186,6 @@ export class QStashDelivery implements EventDelivery { url: this.config.url, body: job, retries: DELIVERY_RETRIES, - // Emitting the same event id twice must not POST twice. QStash refuses ':' in ids. - deduplicationId: `${job.envelope.eventId}_${job.subscriptionId}`.replace(/[^\w.-]/g, "-"), })), ); } diff --git a/packages/mcp-toolkit/src/events/core.test.ts b/packages/mcp-toolkit/src/events/core.test.ts index a5ae8e5..985607d 100644 --- a/packages/mcp-toolkit/src/events/core.test.ts +++ b/packages/mcp-toolkit/src/events/core.test.ts @@ -361,7 +361,7 @@ describe("emit", () => { } }); - it("uses a given eventId, so a repeated emit can be deduplicated", async () => { + it("uses a given eventId, so the host can drop a repeated emit", async () => { const { subscribe, commentCreated, receiver } = setup(); await subscribe({ documentId: "d" }); const result = await commentCreated.emit( @@ -550,6 +550,53 @@ describe("access across users", () => { }); }); +describe("subscription limit", () => { + it("allows 8 live subscriptions per subscriber by default, and refuses the 9th unchallenged", async () => { + const { subscribe, receiver } = setup(); + for (let i = 0; i < 8; i++) { + expect((await subscribe({ documentId: `d${i}` })).error).toBeUndefined(); + } + const challenges = receiver.received.length; + const ninth = await subscribe({ documentId: "d8" }); + expect(ninth.error?.data?.reason).toBe("subscription_limit"); + // Refused before the challenge: a caller at the limit can't make the server POST anywhere. + expect(receiver.received).toHaveLength(challenges); + // Other subscribers have their own allowance. + expect((await subscribe({ documentId: "d8" }, { user: "bob" })).error).toBeUndefined(); + }); + + it("still lets a subscriber at the limit refresh, and frees a slot on unsubscribe", async () => { + const { subscribe, unsubscribe } = setup({ maxSubscriptions: 2 }); + const first = await subscribe({ documentId: "a" }); + await subscribe({ documentId: "b" }); + expect((await subscribe({ documentId: "c" })).error?.data?.reason).toBe("subscription_limit"); + + const refresh = await subscribe({ documentId: "a" }, { url: first.url, secret: first.secret }); + expect(refresh.error).toBeUndefined(); + + await unsubscribe({ documentId: "a" }, first.url); + expect((await subscribe({ documentId: "c" })).error).toBeUndefined(); + }); + + it("counts only live subscriptions", async () => { + const { subscribe, store } = setup({ maxSubscriptions: 1 }); + const first = await subscribe({ documentId: "a" }); + const stored = (await store.get(String(first.result?.id)))!; + store.subscriptions.set(stored.id, { ...stored, expiresAt: Date.now() - 1 }); + expect((await subscribe({ documentId: "b" })).error).toBeUndefined(); + }); + + it("is configurable, and can be turned off", async () => { + const { subscribe } = setup({ maxSubscriptions: Infinity }); + for (let i = 0; i < 12; i++) { + expect((await subscribe({ documentId: `d${i}` })).error).toBeUndefined(); + } + for (const bad of [0, -1, 1.5, Number.NaN]) { + expect(() => setup({ maxSubscriptions: bad })).toThrow(/maxSubscriptions/); + } + }); +}); + describe("define", () => { it("requires authorize", () => { const { events } = setup(); @@ -579,6 +626,37 @@ describe("define", () => { }); }); +describe("routing through the input schema", () => { + it("applies the input's transforms to the payload's values before matching and authorizing", async () => { + const { events, subscribe, receiver } = setup(); + const seen: unknown[] = []; + const pushed = events.define("repo.pushed", { + description: "A push.", + input: z.object({ repo: z.string().trim().toLowerCase() }), + payload: z.object({ repo: z.string(), sha: z.string() }), + authorize: (args) => { + seen.push(args.repo); + return true; + }, + }); + await subscribe({ repo: "Upstash/AgentKit" }, { name: "repo.pushed" }); + await pushed.emit({ repo: " UPSTASH/agentkit", sha: "abc" }); + expect(receiver.deliveries()).toHaveLength(1); + expect(seen).toEqual(["upstash/agentkit", "upstash/agentkit"]); + }); + + it("refuses an emit whose values don't fit the input schema", async () => { + const { events } = setup(); + const build = events.define("build.done", { + description: "A build.", + input: z.object({ id: z.number() }), + payload: z.object({ id: z.string() }), + authorize: () => true, + }); + await expect(build.emit({ id: "x" })).rejects.toThrow(/don't fit the input schema/); + }); +}); + describe("principal", () => { it("refuses to subscribe or unsubscribe a caller it cannot identify", async () => { const { subscribe, unsubscribe, receiver } = setup(); @@ -689,7 +767,10 @@ describe("the secret key", () => { const sub = await subscribe({ documentId: "d" }); const stored = (await store.get(String(sub.result?.id)))!; const otherKey = new SecretBox(Buffer.alloc(32, 9).toString("base64")); - await store.put({ ...stored, encryptedSecret: await otherKey.seal(sub.secret) }); + await store.put( + { ...stored, encryptedSecret: await otherKey.seal(sub.secret) }, + { limit: Infinity }, + ); await commentCreated.emit({ documentId: "d", author: "a", text: "x" }); expect(receiver.deliveries()).toHaveLength(0); diff --git a/packages/mcp-toolkit/src/events/core.ts b/packages/mcp-toolkit/src/events/core.ts index 7e793b0..ae10660 100644 --- a/packages/mcp-toolkit/src/events/core.ts +++ b/packages/mcp-toolkit/src/events/core.ts @@ -27,6 +27,8 @@ const DAY_MS = 24 * 60 * 60 * 1000; const DEFAULT_TTL_MS = 7 * DAY_MS; /** The most a subscription is granted. */ const MAX_TTL_MS = 30 * DAY_MS; +/** Live subscriptions one subscriber may hold, across all events, unless configured. */ +const DEFAULT_MAX_SUBSCRIPTIONS = 8; /** The challenge echo is tiny; never read more than this from a callback. */ const MAX_CHALLENGE_RESPONSE_BYTES = 4096; @@ -45,6 +47,13 @@ export type EventLayerOptions = { secretKey?: string; /** Accept `http://` and private callback URLs. Local development only: it disables the SSRF checks. */ allowInsecureCallbacks?: boolean; + /** + * The most live subscriptions one subscriber may hold, across all events. A subscribe past it is + * refused before the callback is challenged; refreshing an existing subscription always works. + * Every subscription is a webhook per matching emit, so this bounds what one user can make your + * server send. Defaults to 8. `Infinity` turns the limit off. + */ + maxSubscriptions?: number; }; /** Subscription arguments are an object schema, so they can be matched field by field. */ @@ -91,7 +100,7 @@ export type EventHandle = { /** * Validates the payload and hands it to every subscription whose arguments match the payload's * values. `authorize` is checked again before each delivery. Reuse an `eventId` (sent as - * `webhook-id`) to deduplicate an emit. + * `webhook-id`) and the host drops the repeat as a duplicate. */ emit(payload: z.input, options?: { eventId?: string }): Promise<{ eventId: string }>; }; @@ -141,6 +150,15 @@ const listParams = z.looseObject({ cursor: z.string().nullish() }); export function createEventLayer(options: EventLayerOptions): EventLayer { const { store, delivery } = options; const principal = requirePrincipal(options.principal, "createEventLayer"); + const maxSubscriptions = options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS; + if ( + maxSubscriptions !== Infinity && + (!Number.isSafeInteger(maxSubscriptions) || maxSubscriptions < 1) + ) { + throw new Error( + "createEventLayer: maxSubscriptions must be a positive whole number or Infinity", + ); + } const definitions = new Map(); // Resolved on first use, because layers are built at module scope during framework builds. @@ -209,7 +227,7 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { if (bytes > MAX_PAYLOAD_BYTES) { throw new Error(`Event envelope is ${bytes} bytes; the limit is ${MAX_PAYLOAD_BYTES}.`); } - const values = valuesOf(definition.input, payload); + const values = projection(definition, name, payload); const targets = (await store.find(name)).filter((sub) => matches(sub.args, values)); if (targets.length > 0) { await delivery.enqueue(targets.map((sub) => ({ subscriptionId: sub.id, envelope }))); @@ -226,7 +244,7 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { let allowed: boolean; try { allowed = await definition.config.authorize( - valuesOf(definition.input, job.envelope.data) as never, + projection(definition, sub.event, job.envelope.data) as never, { principal: sub.subscriber, phase: "deliver" }, ); } catch { @@ -302,6 +320,10 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { const id = await subscriptionId(subscriber, url, params.name, args); const existing = await store.get(id); + // Checked before the challenge, so a caller at the limit can't make this server POST anywhere. + if (!existing && (await store.count(subscriber)) >= maxSubscriptions) { + throw limitReached(maxSubscriptions); + } // A refresh with the same secret skips the challenge; a new secret proves the callback again. const verified = existing !== null && (await secretBox().open(existing.encryptedSecret)) === secret; @@ -311,16 +333,21 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { const granted = Math.min(requested, MAX_TTL_MS); const now = Date.now(); const expiresAt = now + granted; - await store.put({ - id, - event: params.name, - args, - url, - encryptedSecret: verified ? existing.encryptedSecret : await secretBox().seal(secret), - subscriber, - createdAt: existing?.createdAt ?? new Date(now).toISOString(), - expiresAt, - }); + const stored = await store.put( + { + id, + event: params.name, + args, + url, + encryptedSecret: verified ? existing.encryptedSecret : await secretBox().seal(secret), + subscriber, + createdAt: existing?.createdAt ?? new Date(now).toISOString(), + expiresAt, + }, + { limit: maxSubscriptions }, + ); + // Two concurrent subscribes can both pass the check above; the store's own check is atomic. + if (!stored) throw limitReached(maxSubscriptions); return { id, refreshBefore: new Date(expiresAt - Math.min(granted / 10, DAY_MS)).toISOString(), @@ -337,7 +364,7 @@ export function createEventLayer(options: EventLayerOptions): EventLayer { // The id includes the caller, so a caller can only ever remove their own subscription. const args = parsed.data as Record; const id = await subscriptionId(subscriber, params.delivery.url, params.name, args); - await store.delete({ id, event: params.name }); + await store.delete({ id, event: params.name, subscriber }); } return {}; } @@ -413,14 +440,30 @@ export async function subscriptionId( return `sub_${(await sha256Hex(canonicalJson([subscriber, url, event, args]))).slice(0, 32)}`; } -/** The payload's values for the event's input fields: what an emit is routed and authorized by. */ -function valuesOf(input: EventInputSchema, payload: unknown): Record { +/** + * The payload's values for the event's input fields, parsed with the input schema: what an emit is + * routed and authorized by. Parsing applies the same transforms a subscription's arguments went + * through, so the two compare like for like, and `authorize` gets the type it declares. Throws + * when the payload's values don't fit the input schema, which means the definition is inconsistent. + */ +function projection( + definition: Definition, + name: string, + payload: unknown, +): Record { const record = payload && typeof payload === "object" ? (payload as Record) : {}; - return Object.fromEntries( - Object.keys(input.shape) + const values = Object.fromEntries( + Object.keys(definition.input.shape) .filter((key) => record[key] !== undefined) .map((key) => [key, record[key]]), ); + const parsed = definition.input.safeParse(values); + if (!parsed.success) { + throw new Error( + `Event "${name}": the payload's input fields don't fit the input schema: ${parsed.error.message}`, + ); + } + return parsed.data as Record; } /** A subscription matches when the event has every value it filtered on. */ @@ -447,6 +490,13 @@ function invalid(message: string, reason: string): ProtocolError { return new ProtocolError(INVALID_PARAMS, message, { reason }); } +function limitReached(max: number): ProtocolError { + return invalid( + `Subscription limit reached: at most ${max} live subscriptions per subscriber`, + "subscription_limit", + ); +} + function notAuthenticated(): ProtocolError { return invalid( "Not authenticated: this server could not identify the caller", diff --git a/packages/mcp-toolkit/src/events/types.ts b/packages/mcp-toolkit/src/events/types.ts index f7b9c6d..1c4cec2 100644 --- a/packages/mcp-toolkit/src/events/types.ts +++ b/packages/mcp-toolkit/src/events/types.ts @@ -37,12 +37,18 @@ export type Subscription = { /** Durable storage for subscriptions. The layer does the matching, so a store only indexes by event. */ export interface SubscriptionStore { - /** Creates or replaces a subscription. Must have committed before it resolves. */ - put(subscription: Subscription): Promise; + /** + * Creates or replaces a subscription, and must have committed before it resolves. Atomically + * refuses a new one (resolves `false`, storing nothing) when its subscriber already has `limit` + * live subscriptions across all events; replacing one of theirs is always allowed. + */ + put(subscription: Subscription, options: { limit: number }): Promise; /** A live subscription, or `null`. */ get(id: string): Promise; + /** How many live subscriptions a subscriber has, across all events. */ + count(subscriber: string): Promise; /** Removes a subscription. A no-op when it does not exist. */ - delete(subscription: Pick): Promise; + delete(subscription: Pick): Promise; /** Every live subscription to `event`. */ find(event: string): Promise; } diff --git a/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts b/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts index eb0a0d0..c4b42fe 100644 --- a/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts +++ b/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts @@ -320,7 +320,7 @@ describe("QStashDispatcher.createExecuteHandler", () => { }); describe("QStashDispatcher.dispatch", () => { - it("deduplicates on the task id and publishes only the id", async () => { + it("publishes only the task id", async () => { const published: Record[] = []; const qstash = { publishJSON: async (options: Record) => { @@ -332,8 +332,7 @@ describe("QStashDispatcher.dispatch", () => { const task = makeTask(); await dispatcher.dispatch(task); - // Deduplicated on the task id, so a double dispatch of one task is delivered once. - expect(published[0]?.deduplicationId).toBe(task.taskId); + expect(published[0]).not.toHaveProperty("deduplicationId"); expect(published[0]?.body).toEqual({ taskId: task.taskId }); expect(published[0]?.failureCallback).toBe("https://example.com/api/execute"); }); diff --git a/packages/mcp-toolkit/src/tasks/backends/qstash.ts b/packages/mcp-toolkit/src/tasks/backends/qstash.ts index 2fd5b78..3b77c47 100644 --- a/packages/mcp-toolkit/src/tasks/backends/qstash.ts +++ b/packages/mcp-toolkit/src/tasks/backends/qstash.ts @@ -154,7 +154,6 @@ export class QStashDispatcher implements TaskDispatcher { retryDelay: this.config.retryDelay ?? DEFAULT_RETRY_DELAY, // Comes back to the same route once retries are exhausted, and settles the task `failed`. failureCallback: this.config.url, - deduplicationId: task.taskId, }); } diff --git a/packages/mcp-toolkit/src/tasks/core.test.ts b/packages/mcp-toolkit/src/tasks/core.test.ts index 43ed48e..6cdb4b7 100644 --- a/packages/mcp-toolkit/src/tasks/core.test.ts +++ b/packages/mcp-toolkit/src/tasks/core.test.ts @@ -416,6 +416,57 @@ describe("createTaskLayer over MCP", () => { expect(text(await live.status(taskId, "alice"))).toMatch(/Unknown task/); }); + it("hands the handler the principal that started the task", async () => { + const seen: string[] = []; + live = await harness(async (_args, task) => { + seen.push(task.principal); + return {}; + }); + await live.start("x", "alice"); + await live.start("x", "bob"); + await live.dispatcher.drain(); + expect(seen.sort()).toEqual(["alice", "bob"]); + }); + + it("runs authorize with the args, principal and auth before storing anything", async () => { + const calls: { args: unknown; principal: string; userId: unknown }[] = []; + const ran: string[] = []; + live = await harness( + async ({ topic }) => { + ran.push(topic); + return {}; + }, + {}, + { + authorize: ({ topic }, { principal, auth }) => { + calls.push({ args: { topic }, principal, userId: auth?.extra?.userId }); + return topic !== "secret"; + }, + }, + ); + const refused = await live.call("generate_report", { topic: "secret" }, "alice"); + expect(refused.isError).toBe(true); + expect(text(refused)).toMatch(/Not authorized/); + expect(refused.structuredContent).toBeUndefined(); + + const allowed = await live.start("open", "alice"); + await live.dispatcher.drain(); + expect(ran).toEqual(["open"]); + expect((await live.store.get(allowed))?.status).toBe("completed"); + expect(calls).toEqual([ + { args: { topic: "secret" }, principal: "alice", userId: "alice" }, + { args: { topic: "open" }, principal: "alice", userId: "alice" }, + ]); + }); + + it("does not ask authorize for a caller it cannot identify", async () => { + let asked = false; + live = await harness(steppedHandler(1, 1), {}, { authorize: () => (asked = true) }); + const result = await live.call("generate_report", { topic: "x" }, null); + expect(text(result)).toMatch(/Not authenticated/); + expect(asked).toBe(false); + }); + it("requires a principal", () => { expect(() => createTaskLayer({ @@ -540,6 +591,16 @@ describe("createTaskLayer over MCP", () => { await live.dispatcher.drain(); }); + it("refuses defaults that aren't positive whole milliseconds", () => { + const base = { store: new MemoryTaskStore(), dispatcher: new InlineDispatcher(), principal }; + for (const bad of [0, -1, 1.5, Number.NaN, Infinity]) { + expect(() => createTaskLayer({ ...base, defaults: { ttlMs: bad } })).toThrow(/ttlMs/); + expect(() => createTaskLayer({ ...base, defaults: { pollIntervalMs: bad } })).toThrow( + /pollIntervalMs/, + ); + } + }); + 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. diff --git a/packages/mcp-toolkit/src/tasks/core.ts b/packages/mcp-toolkit/src/tasks/core.ts index 447bb62..6871ea8 100644 --- a/packages/mcp-toolkit/src/tasks/core.ts +++ b/packages/mcp-toolkit/src/tasks/core.ts @@ -5,10 +5,12 @@ */ import type { McpServer, StandardSchemaWithJSON } from "@modelcontextprotocol/server"; import * as z from "zod"; +import type { AuthInfo } from "@modelcontextprotocol/server"; import { callerOf, requirePrincipal, resolvePrincipal, + type Caller, type PrincipalResolver, } from "../shared/auth.js"; import { INTERNAL_ERROR } from "../shared/env.js"; @@ -54,6 +56,23 @@ export type TaskToolConfig = { inputSchema: Schema; /** Status message on success. Defaults to `"Completed"`. */ completedMessage?: string; + /** + * Whether this caller may start the task with these arguments, e.g. whether they can write to + * the workspace the arguments name. Runs before anything is stored or queued; `false` refuses + * the call. Optional: without it, any authenticated caller may start the task. The handler also + * gets `task.principal`, for checks that belong in the work itself. + */ + authorize?: (args: InferArgs, caller: TaskAuthorizeCaller) => boolean | Promise; +}; + +/** What a task's `authorize` is told about the caller. */ +export type TaskAuthorizeCaller = { + /** The caller's id, from `principal`. */ + principal: string; + /** The verified `AuthInfo` your route passed to the SDK, if any. */ + auth: AuthInfo | undefined; + /** The raw, unverified HTTP request. */ + request: Request | undefined; }; type InferArgs = Schema extends { @@ -103,15 +122,19 @@ export function createTaskLayer( ): TaskLayer { const { store, dispatcher } = options; const principal = requirePrincipal(options.principal, "createTaskLayer"); - const ttlMs = options.defaults?.ttlMs ?? DEFAULT_TTL_MS; - const pollIntervalMs = options.defaults?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const ttlMs = positiveInteger(options.defaults?.ttlMs ?? DEFAULT_TTL_MS, "defaults.ttlMs"); + const pollIntervalMs = positiveInteger( + options.defaults?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, + "defaults.pollIntervalMs", + ); // Keyed by tool name: a delivery only carries a task id, and the record names its tool. const definitions = new Map< string, { config: TaskToolConfig; handler: TaskHandler } >(); - const callerId = (context: unknown) => resolvePrincipal(principal, callerOf(context)); + const callerId = (context: unknown, caller: Caller = callerOf(context)) => + resolvePrincipal(principal, caller); function define( name: string, @@ -194,8 +217,13 @@ export function createTaskLayer( } async function startTask(name: string, args: unknown, context: unknown) { - const owner = await callerId(context); + const caller = callerOf(context); + const owner = await callerId(context, caller); if (!owner) return errorResult(NOT_AUTHENTICATED); + const authorize = definitions.get(name)?.config.authorize; + if (authorize && !(await authorize(args, { principal: owner, ...caller }))) { + return errorResult(NOT_AUTHORIZED); + } const now = new Date().toISOString(); const task: Task = { taskId: crypto.randomUUID(), @@ -256,6 +284,7 @@ export function createTaskLayer( let writes = 0; const taskContext: TaskContext = { taskId, + principal: task.owner, update: async (statusMessage) => { const write = () => store.update(taskId, { statusMessage }); await (journal ? journal(`mcp-task:update:${++writes}`, write) : write()); @@ -290,6 +319,15 @@ export function createTaskLayer( } const NOT_AUTHENTICATED = "Not authenticated: this server could not identify the caller."; +const NOT_AUTHORIZED = "Not authorized to start this task with these arguments."; + +/** Validates a duration option: a positive, finite whole number of milliseconds. */ +function positiveInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`createTaskLayer: ${name} must be a positive whole number of milliseconds`); + } + return value; +} const unknownTask = (taskId: string) => `Unknown task: ${taskId}. It may have expired, or the id may be wrong.`; diff --git a/packages/mcp-toolkit/src/tasks/index.ts b/packages/mcp-toolkit/src/tasks/index.ts index 28fffb8..f918107 100644 --- a/packages/mcp-toolkit/src/tasks/index.ts +++ b/packages/mcp-toolkit/src/tasks/index.ts @@ -8,6 +8,7 @@ export { type TaskLayer, type TaskLayerOptions, type TaskToolConfig, + type TaskAuthorizeCaller, } from "./core.js"; export type { Caller, PrincipalResolver } from "../shared/auth.js"; diff --git a/packages/mcp-toolkit/src/tasks/types.ts b/packages/mcp-toolkit/src/tasks/types.ts index 5dca768..949fabb 100644 --- a/packages/mcp-toolkit/src/tasks/types.ts +++ b/packages/mcp-toolkit/src/tasks/types.ts @@ -69,7 +69,7 @@ export interface TaskStore { * nothing for a queue, the `WorkflowContext` for Upstash Workflow. */ export interface TaskDispatcher { - /** Durably accepts a delivery. Must be idempotent per task id. */ + /** Durably accepts a delivery. The layer calls it once per task. */ dispatch(task: Task): Promise; /** Stops pending deliveries, when the transport can. Idempotent. */ @@ -93,6 +93,11 @@ export type TaskJournal = (name: string, fn: () => Promise) => Promise; /** What every handler is handed, whatever the transport. */ export type TaskContext = { taskId: string; + /** + * The caller that started the task: what `principal` returned for that call. Use it to scope + * what the handler touches to that user; never take a user id from the tool arguments. + */ + principal: string; /** Publishes a progress line that the next `task_status` poll will see. */ update(statusMessage: string): Promise; /** True once the client cancelled, or the record expired. Cooperative: check at step boundaries. */ diff --git a/packages/mcp-toolkit/src/test-support.ts b/packages/mcp-toolkit/src/test-support.ts index e9770fc..f1b5be1 100644 --- a/packages/mcp-toolkit/src/test-support.ts +++ b/packages/mcp-toolkit/src/test-support.ts @@ -265,8 +265,17 @@ export class InlineDispatcher extends ManualDispatcher { export class MemorySubscriptionStore implements SubscriptionStore { readonly subscriptions = new Map(); - async put(subscription: Subscription): Promise { + async put(subscription: Subscription, options: { limit: number }): Promise { + const replacing = this.subscriptions.has(subscription.id); + if (!replacing && (await this.count(subscription.subscriber)) >= options.limit) return false; this.subscriptions.set(subscription.id, { ...subscription }); + return true; + } + + async count(subscriber: string): Promise { + return [...this.subscriptions.values()].filter( + (sub) => sub.subscriber === subscriber && sub.expiresAt > Date.now(), + ).length; } async get(id: string): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b07afc0..bea7a4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,7 +416,7 @@ importers: specifier: ^2.11.3 version: 2.12.0 '@upstash/redis': - specifier: ^1.38.0 + specifier: ^1.38.4 version: 1.38.4 '@upstash/workflow': specifier: ^1.3.3