diff --git a/.changeset/mcp-toolkit-initial.md b/.changeset/mcp-toolkit-initial.md new file mode 100644 index 0000000..a9095e5 --- /dev/null +++ b/.changeset/mcp-toolkit-initial.md @@ -0,0 +1,31 @@ +--- +"@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`, 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); 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, 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. + +`@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/.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 diff --git a/CLAUDE.md b/CLAUDE.md index b4be638..2b07143 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,9 +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-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` 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), -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-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 implements TanStack AI's own backend contracts (see its section below) — keep it. @@ -434,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 @@ -676,6 +678,211 @@ 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 toolkit facts (`packages/mcp-toolkit`) — IMPORTANT +- **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,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 + 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. +- **`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 `{ 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. `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` 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()`, 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. +- **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 `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. +- **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. +- **`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. +- 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; + 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 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). +- **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, 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** + 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(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: + 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 + 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/{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 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| + 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]`), 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 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`. + **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*. + ## Eve framework facts - **The repo is split (since 2026-09-30): `packages/eve-extension` + `examples/eve-extension-demo` are on `eve@0.69.0` (extension peer `>=0.69.0`, since 2026-10-01; 0.68.0 / `>=0.68.0` before that); diff --git a/README.md b/README.md index 901ae3d..7453293 100644 --- a/README.md +++ b/README.md @@ -15,6 +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-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 @@ -33,15 +34,21 @@ 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 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 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), [`eve-extension-demo`](./examples/eve-extension-demo) (an eve agent that mounts -`@upstash/agentkit-eve-extension`), and [`tanstack-ai-demo`](./examples/tanstack-ai-demo) (a +`@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). +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 separate events server that fires `deploy.finished`, with the client's +wire log on screen). ## Development diff --git a/examples/mcp-toolkit-demo/.env.example b/examples/mcp-toolkit-demo/.env.example new file mode 100644 index 0000000..d66dba8 --- /dev/null +++ b/examples/mcp-toolkit-demo/.env.example @@ -0,0 +1,21 @@ +# 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= + +# 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/.gitignore b/examples/mcp-toolkit-demo/.gitignore new file mode 100644 index 0000000..3556585 --- /dev/null +++ b/examples/mcp-toolkit-demo/.gitignore @@ -0,0 +1,4 @@ +node_modules +.next +.env* +!.env.example diff --git a/examples/mcp-toolkit-demo/README.md b/examples/mcp-toolkit-demo/README.md new file mode 100644 index 0000000..c672255 --- /dev/null +++ b/examples/mcp-toolkit-demo/README.md @@ -0,0 +1,134 @@ +# MCP toolkit demo + +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. + +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/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/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/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` | 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 + +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, 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 +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. + +## Tests + +No model and no QStash account: the checks speak raw JSON-RPC, as a model and a host would. + +```bash +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: + +| | 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 +durability 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.** `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`, 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: + +```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 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 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/deploys/route.ts b/examples/mcp-toolkit-demo/app/api/deploy-watch/deploys/route.ts new file mode 100644 index 0000000..a0a2155 --- /dev/null +++ b/examples/mcp-toolkit-demo/app/api/deploy-watch/deploys/route.ts @@ -0,0 +1,30 @@ +/** + * 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"}' + * + * 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"; + +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..fc7be2b --- /dev/null +++ b/examples/mcp-toolkit-demo/app/api/deploy-watch/route.ts @@ -0,0 +1,13 @@ +/** 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"; + +const handler = createMcpHandler(() => createServer()); + +export async function POST(request: Request): Promise { + // 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/execute-workflow/route.ts b/examples/mcp-toolkit-demo/app/api/execute-workflow/route.ts new file mode 100644 index 0000000..7bee011 --- /dev/null +++ b/examples/mcp-toolkit-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-toolkit-demo/app/api/execute/route.ts b/examples/mcp-toolkit-demo/app/api/execute/route.ts new file mode 100644 index 0000000..cd63d1d --- /dev/null +++ b/examples/mcp-toolkit-demo/app/api/execute/route.ts @@ -0,0 +1,15 @@ +/** + * Where QStash delivers a task — and, once retries are exhausted, its failure callback. + * + * One line, because everything that has to be right here belongs to the transport: verifying the + * signature, reading the task id, telling a delivery from a failure callback, and choosing the + * status code that decides whether QStash tries again. + */ +import { tasks } from "../../lib/qstash-server"; + +export const dynamic = "force-dynamic"; +// The demo tool sleeps ~10s and must finish inside this one invocation. That limit is the reason +// the workflow server exists. +export const maxDuration = 60; + +export const POST = tasks.createExecuteHandler(); diff --git a/examples/mcp-toolkit-demo/app/api/mcp-workflow/route.ts b/examples/mcp-toolkit-demo/app/api/mcp-workflow/route.ts new file mode 100644 index 0000000..807d5f5 --- /dev/null +++ b/examples/mcp-toolkit-demo/app/api/mcp-workflow/route.ts @@ -0,0 +1,13 @@ +/** 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"; + +const handler = createMcpHandler(() => createServer()); + +export async function POST(request: Request): Promise { + // 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 new file mode 100644 index 0000000..3daf2f3 --- /dev/null +++ b/examples/mcp-toolkit-demo/app/api/mcp/route.ts @@ -0,0 +1,18 @@ +/** + * The MCP endpoint for the **QStash** server. + * + * 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 { serveMcp } from "../../lib/auth"; +import { createServer } from "../../lib/qstash-server"; + +export const dynamic = "force-dynamic"; + +const handler = createMcpHandler(() => createServer()); + +export async function POST(request: Request): Promise { + // 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 new file mode 100644 index 0000000..2ff0519 --- /dev/null +++ b/examples/mcp-toolkit-demo/app/api/receiver/route.ts @@ -0,0 +1,49 @@ +/** + * 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. + * - `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. + */ +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 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 new file mode 100644 index 0000000..6e5af2e --- /dev/null +++ b/examples/mcp-toolkit-demo/app/globals.css @@ -0,0 +1,419 @@ +: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.completed { + color: var(--accent); +} + +.badge.failed { + color: var(--red); +} + +.badge.cancelled { + color: var(--muted); +} + +.status-line { + margin-top: 10px; + color: var(--muted); + font-size: 13px; + min-height: 20px; +} + +.bar { + margin-top: 10px; + height: 4px; + background: #0a1210; + border-radius: 999px; + overflow: hidden; +} + +.bar > span { + display: block; + height: 100%; + background: var(--accent); + transition: width 0.4s ease; +} + +.bar.cancelled > span { + background: var(--muted); +} + +.bar.failed > span { + background: var(--red); +} + +.meta { + margin-top: 10px; + display: flex; + gap: 14px; + flex-wrap: wrap; + font-family: var(--mono); + font-size: 11px; + color: var(--muted); +} + +.result { + margin-top: 12px; + border-top: 1px dashed var(--line); + padding-top: 12px; +} + +.result p { + margin: 0 0 8px; +} + +pre { + margin: 0; + font-family: var(--mono); + font-size: 11.5px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + color: var(--text); +} + +.log { + max-height: 620px; + overflow-y: auto; + padding: 8px 0; +} + +.frame { + padding: 8px 16px; + border-bottom: 1px solid #1a2422; +} + +.frame:last-child { + border-bottom: 0; +} + +.frame-head { + display: flex; + gap: 8px; + align-items: baseline; + font-family: var(--mono); + font-size: 11px; +} + +.frame-head .dir { + color: var(--accent); + font-weight: 700; +} + +.frame-head .dir.in { + color: var(--blue); +} + +.frame-head .method { + color: var(--text); +} + +.frame-head .time { + color: #4d635b; + margin-left: auto; +} + +.frame pre { + margin-top: 5px; + color: var(--muted); + font-size: 11px; +} + +.empty { + color: var(--muted); + font-size: 13px; + padding: 8px 0; +} + +.callout { + margin-top: 20px; + border: 1px solid var(--line); + border-left: 3px solid var(--accent-dim); + border-radius: 8px; + padding: 14px 16px; + background: var(--panel); +} + +.callout h3 { + margin: 0 0 6px; + font-size: 13px; +} + +.callout p { + margin: 0 0 8px; + color: var(--muted); + font-size: 13px; +} + +.callout code, +.inline-code { + font-family: var(--mono); + font-size: 12px; + background: #0a1210; + border: 1px solid var(--line); + border-radius: 4px; + padding: 1px 5px; +} + +.banner { + border: 1px solid #5a3a2a; + background: #2a1a12; + color: #ffd9a8; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 20px; + font-size: 13px; +} + +.drivers { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.driver { + flex: 1; + background: var(--bg); + border: 1px solid var(--line); + color: var(--muted); + border-radius: 8px; + padding: 8px 12px; + text-align: left; + font-weight: 600; + font-size: 13px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; +} + +.driver span { + font-weight: 400; + font-size: 11px; + color: #55706a; +} + +.driver.on { + border-color: var(--accent-dim); + color: var(--accent); +} + +.driver.on span { + color: var(--muted); +} diff --git a/examples/mcp-toolkit-demo/app/layout.tsx b/examples/mcp-toolkit-demo/app/layout.tsx new file mode 100644 index 0000000..a4a6156 --- /dev/null +++ b/examples/mcp-toolkit-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-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 new file mode 100644 index 0000000..d0feb15 --- /dev/null +++ b/examples/mcp-toolkit-demo/app/lib/deploy-watch.ts @@ -0,0 +1,97 @@ +/** + * 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 } 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"; + +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. + // 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), +}); + +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, + // 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. */ +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/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 new file mode 100644 index 0000000..8d99668 --- /dev/null +++ b/examples/mcp-toolkit-demo/app/lib/mcp-client.ts @@ -0,0 +1,168 @@ +/** + * A hand-rolled MCP client for the browser. + * + * 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"; +/** 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" | "completed" | "failed" | "cancelled"; + +export type WireTask = { + taskId: string; + status: TaskStatus; + statusMessage?: string; + createdAt: string; + lastUpdatedAt: string; + ttlMs: number; + pollIntervalMs: number; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +}; + +export const TERMINAL: ReadonlySet = new Set([ + "completed", + "failed", + "cancelled", +]); + +/** One direction of one JSON-RPC exchange, for the wire log. */ +export type Frame = { + id: number; + direction: "out" | "in"; + method: string; + payload: unknown; + at: number; +}; + +let frameId = 0; +let requestId = 0; + +export type RpcOptions = { + /** Called once for the request and once for the response, so the UI can render the wire. */ + onFrame?: (frame: Frame) => void; + /** Which of the two servers to talk to. Defaults to the QStash one. */ + server?: ServerKey; +}; + +/** + * Sends one stateless JSON-RPC request. + * + * There is no initialize handshake and no session header any more: the protocol version, who the + * client is, and 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, + 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-toolkit-demo", version: "0.1.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }; + + 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. + if (method === "tools/call" && typeof params.name === "string") headers["mcp-name"] = params.name; + + options.onFrame?.({ id: ++frameId, direction: "out", method, payload: body, at: Date.now() }); + + const response = await fetch(SERVERS[options.server ?? "qstash"].endpoint, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + const message = await readMessage(response); + options.onFrame?.({ + id: ++frameId, + direction: "in", + method, + payload: message, + at: Date.now(), + }); + + if (message?.error) throw new RpcError(method, message.error); + if (!response.ok) throw new Error(`${method} failed with HTTP ${response.status}`); + return message?.result as T; +} + +/** + * 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, + 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-toolkit-demo/app/lib/qstash-server.ts b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts new file mode 100644 index 0000000..7bb63d4 --- /dev/null +++ b/examples/mcp-toolkit-demo/app/lib/qstash-server.ts @@ -0,0 +1,89 @@ +/** + * 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 } 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 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 + * the `TaskContext` — `taskId`, `update`, `isCancelled`. + */ +export const tasks = createTaskLayer({ + store: new RedisTaskStore({ prefix: "mcp:task:qstash:" }), + dispatcher, + // 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)); + +const STEP_NAMES = ["gathering sources", "reading", "outlining", "writing"]; +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); + } + + // 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 }, + }; + }, +); + +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" }); + tasks.register(server); + return server; +} diff --git a/examples/mcp-toolkit-demo/app/lib/workflow-server.ts b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts new file mode 100644 index 0000000..d893e9f --- /dev/null +++ b/examples/mcp-toolkit-demo/app/lib/workflow-server.ts @@ -0,0 +1,80 @@ +/** + * 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. 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/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, + // 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 + * `define`, so the compiler rejects a workflow handler wired to a queue. + */ +export const tasks = createTaskLayer({ + store: new RedisTaskStore({ prefix: "mcp:task:workflow:" }), + dispatcher, + // Who is calling: the user id the MCP route verified (see `auth.ts`). Throws when there is none. + principal, +}); + +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 {}; + } + + // `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}.` }, + }; + }, +); + +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); + return server; +} diff --git a/examples/mcp-toolkit-demo/app/page.tsx b/examples/mcp-toolkit-demo/app/page.tsx new file mode 100644 index 0000000..6134a1b --- /dev/null +++ b/examples/mcp-toolkit-demo/app/page.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + callTool, + rpc, + SERVERS, + TERMINAL, + type Frame, + type ServerKey, + type WireTask, +} from "./lib/mcp-client"; + +const TOOL_NAME = "generate_report"; + +type TrackedTask = { + server: ServerKey; + taskId: string; + topic: string; + startedAt: number; + lastPolledAt: number; + polls: number; + wire: WireTask; +}; + +export default function Page() { + const [topic, setTopic] = useState("coffee trends"); + const [server, setServer] = useState("qstash"); + const [tasks, setTasks] = useState([]); + const [frames, setFrames] = useState([]); + const [tools, setTools] = useState(null); + const [starting, setStarting] = useState(false); + const [error, setError] = useState(null); + const [, setTick] = useState(0); + + const onFrame = useCallback((frame: Frame) => { + setFrames(previous => [frame, ...previous].slice(0, 80)); + }, []); + + // A plain `tools/list`: the 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))) + .catch(cause => setError(String(cause))); + }, [onFrame, server]); + + // Re-render once a second so the elapsed counters move. + useEffect(() => { + const id = setInterval(() => setTick(n => n + 1), 1000); + return () => clearInterval(id); + }, []); + + const tasksRef = useRef(tasks); + tasksRef.current = tasks; + + // The client polls `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(() => { + const now = Date.now(); + for (const task of tasksRef.current) { + if (TERMINAL.has(task.wire.status)) continue; + if (now - task.lastPolledAt < task.wire.pollIntervalMs) continue; + markPolled(task.taskId); + void poll(task.taskId, task.server); + } + }, 400); + return () => clearInterval(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function markPolled(taskId: string) { + setTasks(previous => + previous.map(task => (task.taskId === taskId ? { ...task, lastPolledAt: Date.now() } : task)), + ); + } + + async function poll(taskId: string, from: ServerKey) { + try { + const wire = await callTool("task_status", { taskId }, { onFrame, server: from }); + setTasks(previous => + previous.map(task => + task.taskId === taskId ? { ...task, wire, polls: task.polls + 1 } : task, + ), + ); + } catch (cause) { + setError(String(cause)); + } + } + + async function start(event: React.FormEvent) { + event.preventDefault(); + if (!topic.trim() || starting) return; + setStarting(true); + setError(null); + try { + // A normal `tools/call`. 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 => [ + { + server, + taskId: wire.taskId, + topic: topic.trim(), + startedAt: Date.now(), + lastPolledAt: Date.now(), + polls: 0, + wire, + }, + ...previous, + ]); + } catch (cause) { + setError(String(cause)); + } finally { + setStarting(false); + } + } + + async function cancel(taskId: string, from: ServerKey) { + try { + await callTool("task_cancel", { taskId }, { onFrame, server: from }); + await poll(taskId, from); + } catch (cause) { + setError(String(cause)); + } + } + + return ( + <> +
+

MCP Tasks on Upstash

+

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

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

Call the tool

+
+
+ {(Object.keys(SERVERS) as ServerKey[]).map(key => ( + + ))} +
+
+ setTopic(event.target.value)} + placeholder="a topic to report on" + aria-label="Report topic" + /> + +
+

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

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

No tasks yet. Run the tool to create one.

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

Prove the work is durable, not just the record

+

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

+
+
+ +
+

+ Wire log + +

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

+ Nothing sent yet. +

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

{wire.statusMessage ?? "—"}

+ +
+ +
+ +
+ {elapsed}s elapsed + {task.polls} polls + kept for {formatDuration(wire.ttlMs)} + every {wire.pollIntervalMs}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); +} + +/** 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/next-env.d.ts b/examples/mcp-toolkit-demo/next-env.d.ts new file mode 100644 index 0000000..c4b7818 --- /dev/null +++ b/examples/mcp-toolkit-demo/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/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-toolkit-demo/next.config.ts b/examples/mcp-toolkit-demo/next.config.ts new file mode 100644 index 0000000..cb651cd --- /dev/null +++ b/examples/mcp-toolkit-demo/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/examples/mcp-toolkit-demo/package.json b/examples/mcp-toolkit-demo/package.json new file mode 100644 index 0000000..8e57d77 --- /dev/null +++ b/examples/mcp-toolkit-demo/package.json @@ -0,0 +1,30 @@ +{ + "name": "mcp-toolkit-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", + "e2e": "node scripts/e2e.mjs" + }, + "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "@upstash/mcp-toolkit": "workspace:*", + "@upstash/qstash": "^2.11.3", + "@upstash/redis": "^1.38.0", + "next": "16.2.9", + "react": "19.2.6", + "react-dom": "19.2.6", + "zod": "4.4.3", + "@upstash/workflow": "^1.3.3" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "19.2.15", + "@types/react-dom": "19.2.3", + "typescript": "^5" + } +} diff --git a/examples/mcp-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 new file mode 100644 index 0000000..79ff61c --- /dev/null +++ b/examples/mcp-toolkit-demo/scripts/smoke.mjs @@ -0,0 +1,302 @@ +// 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"; +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 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; +function post(path, method, params, user) { + 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 (user) headers.authorization = `Bearer ${user}`; + return fetch(`${BASE}${path}`, { + 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": {}, + }, + }, + }), + }); +} + +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 Object.assign(new Error(`${method}: ${JSON.stringify(json.error)}`), { rpc: json.error }); + } + return json.result; +} + +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 }); + +/** 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; +} + +/** 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}`); +} + +let passed = 0; +async function test(name, body) { + console.log(`\n== ${name} ==`); + await body(); + passed++; +} + +// --------------------------------------------------------------------------------------------- +// 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") { + 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" }), + }; + } + + 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 ${passed} E2E CHECKS PASSED (${ENDPOINT})`); diff --git a/examples/mcp-toolkit-demo/tsconfig.json b/examples/mcp-toolkit-demo/tsconfig.json new file mode 100644 index 0000000..803331c --- /dev/null +++ b/examples/mcp-toolkit-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-toolkit/README.md b/packages/mcp-toolkit/README.md new file mode 100644 index 0000000..0dcf8c4 --- /dev/null +++ b/packages/mcp-toolkit/README.md @@ -0,0 +1,507 @@ +# @upstash/mcp-toolkit + +Durable building blocks for MCP servers built on the official TypeScript SDK +(`@modelcontextprotocol/server`), running on Upstash Redis and QStash. + +- **`/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`. + +```bash +npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod +``` + +Environment variables: + +```bash +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 +``` + +## 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: + +```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; +} +``` + +
+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"`. + +
+ +## Long-running tools + +```ts +// lib/tasks.ts +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` }), + principal, +}); + +// 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() }) }, + async ({ topic }, task) => { + await task.update("Reading sources"); // the model sees this when it polls + return { content: [{ type: "text", text: await writeReport(topic) }] }; + }, +); + +export function createServer() { + const server = new McpServer({ name: "reports", version: "1.0.0" }); + tasks.register(server); // adds generate_report, task_status and task_cancel + return server; +} +``` + +```ts +// app/api/mcp/route.ts +import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; +import { createServer } from "../../lib/tasks"; + +const handler = createMcpHandler(() => createServer()); + +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: QStash delivers the work here +import { tasks } from "../../lib/tasks"; + +export const POST = tasks.createExecuteHandler(); +``` + +`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. + +**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 + +```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": 86400000, "pollIntervalMs": 2000 } } + +// 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" }, + { "type": "text", "text": "Report on coffee" }], + "structuredContent": { "taskId": "0e30…", "status": "completed", "result": { "content": [ … ] } } } +``` + +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. + +If the model stops polling, nothing is lost: the work finishes anyway, and the result can be read +until the task expires (1 day by default; set `defaults: { ttlMs }` on the layer). + +
+ +
+Work that takes longer than one function invocation: WorkflowDispatcher + +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 +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/upstash"; + +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + principal, +}); + +tasks.define( + "migrate_workspace", + { + 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, task.principal)); + 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)); + } + return { content: [{ type: "text", text: "Done" }] }; + }, +); +``` + +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: + +- 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. + +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. The default is 1 day; set `ttlMs` higher for work that +can take longer. + +| | `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 | + +
+ +
+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. + +
+ +
+Why plain tools and not the MCP Tasks extension? + +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. + +
+ +## 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` }), + principal, +}); + +export const commentCreated = events.define("comment.created", { + 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), +}); +``` + +Call `events.register(server)` in `createServer()`, next to `tasks.register(server)`, and add the +route QStash delivers to: + +```ts +// app/api/events/route.ts +import { events } from "../../lib/events"; + +export const POST = events.createDeliveryHandler(); +``` + +Then emit from anywhere in your server code: + +```ts +await commentCreated.emit({ documentId: "doc_123", text: "Ship it?" }); +``` + +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. + +
+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. 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: + +- **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. + +`() => true` lets every authenticated subscriber hear every matching event. + +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. + +
+ +
+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. +- **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 + them) and event replay (`cursor` is always `null`). + +
+ +
+Which hosts subscribe today + +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). + +
+ +## Reference + +
+Who can see what + +**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 +QStash deliveries and never returns a task. + +**Events.** See "Matching and `authorize`" above. The token is never stored, so the +delivery-time check gets no `auth`. + +**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. + +
+ +
+What is stored in Redis + +Treat the Redis credentials like any other production secret. + +**`mcp:task:`**: a hash with one JSON-encoded field per property: `taskId`, `name`, +`args`, `owner`, `status`, `statusMessage`, `result` / `error`, `createdAt`, `lastUpdatedAt`, +`ttlMs`, `pollIntervalMs`. + +- `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. + +**`mcp-events:sub:`**: the subscription (`event`, `args`, `url`, `encryptedSecret`, +`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. +The toolkit never stores the caller's token, the request, the plaintext webhook secret or +`MCP_EVENTS_SECRET_KEY`. + +
+ +
+All options + +**`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: +`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`, `authorize(args, { principal, auth, request })`. + +**`createEventLayer`**: `store`, `delivery` and `principal` are required, and so is `secretKey` +unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `maxSubscriptions` (8), `allowInsecureCallbacks`. + +**`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`. + +**`QStashDispatcher`**: `url` is required. Optional: `qstash`, `receiver`, `retries` (5), +`retryDelay`, `enableTelemetry`. + +**`WorkflowDispatcher`**: `url` is required. Optional: `client`, `qstash`, `receiver`, `retries`, +`enableTelemetry`. + +**`QStashDelivery`**: `url` is required. Optional: `qstash`, `receiver`, `enableTelemetry`. +Deliveries are retried 3 times with QStash's backoff. + +`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. + +**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`. + +
+ +
+Custom backends + +`/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 +} + +interface TaskDispatcher { + dispatch(task: Task): Promise; // called once per task + cancel(taskId: string): Promise; + createExecuteHandler(endpoints: TaskEndpoints): (request: Request) => Promise; +} + +interface SubscriptionStore { + // 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; + 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 +} + +interface EventDelivery { + enqueue(jobs: DeliveryJob[]): Promise; + createDeliveryHandler(send: SendJob): (request: Request) => Promise; +} +``` + +`/events` also exports `verifyWebhook` (Standard Webhooks) for writing a receiver. + +
+ +
+Not implemented yet + +- `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. + +
diff --git a/packages/mcp-toolkit/package.json b/packages/mcp-toolkit/package.json new file mode 100644 index 0000000..999f935 --- /dev/null +++ b/packages/mcp-toolkit/package.json @@ -0,0 +1,68 @@ +{ + "name": "@upstash/mcp-toolkit", + "version": "0.0.0", + "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-toolkit" + }, + "homepage": "https://github.com/upstash/agentkit/tree/main/packages/mcp-toolkit", + "bugs": { + "url": "https://github.com/upstash/agentkit/issues" + }, + "type": "module", + "exports": { + "./tasks": { + "types": "./dist/tasks/index.d.ts", + "import": "./dist/tasks/index.js" + }, + "./events": { + "types": "./dist/events/index.d.ts", + "import": "./dist/events/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", + "events", + "webhooks", + "durable", + "long-running", + "ai", + "agent" + ], + "dependencies": { + "@upstash/qstash": "^2.11.3", + "@upstash/redis": "^1.38.4", + "@upstash/workflow": "^1.3.3", + "zod": "^4.2.0" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0" + }, + "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "dotenv": "^16.4.5" + } +} 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..21e4726 --- /dev/null +++ b/packages/mcp-toolkit/src/events/backends/qstash.test.ts @@ -0,0 +1,226 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { QStashDelivery, RedisSubscriptionStore } from "./qstash.js"; +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); + }); + + it("round-trips a subscription, keeping a numeric-looking subscriber a string", async () => { + const sub = makeSub(); + 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, { limit: Infinity }); + const pttl = await redis.pttl(subKey(sub.id)); + expect(pttl).toBeGreaterThan(0); + expect(pttl).toBeLessThanOrEqual(30_000); + }); + + 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 elsewhere = makeSub({ event: "other", args: {} }); + const expired = makeSub({ event: "find" }); + 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 }); + + const found = await store.find("find"); + expect(found.map((s) => s.id).sort()).toEqual([all.id, one.id].sort()); + expect(await store.find("nothing")).toEqual([]); + }); + + 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, { limit: Infinity }))); + } + 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 keeps one index entry", async () => { + const sub = makeSub({ event: "refresh", expiresAt: Date.now() + 10_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" }), { 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, { limit: Infinity }); + await store.delete(sub); + expect(await store.get(sub.id)).toBeNull(); + expect(await store.find("delete")).toEqual([]); + expect(await redis.zcard(indexKey("delete"))).toBe(0); + await store.delete(sub); // idempotent + }); +}); + +describe("QStashDelivery", () => { + const URL = "https://example.com/api/events"; + + const job: DeliveryJob = { + subscriptionId: "sub_1", + envelope: { + eventId: "evt_1", + name: "push", + timestamp: "2026-10-07T00:00:00Z", + data: {}, + cursor: null, + }, + }; + + const connected = (done: boolean) => { + const sent: DeliveryJob[] = []; + const delivery = new QStashDelivery({ url: URL, receiver: testReceiver() }); + const handler = delivery.createDeliveryHandler(async (j) => { + sent.push(j); + return done; + }); + return { handler, sent }; + }; + + 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: signed.headers, + body: await signed.text(), + }); + expect((await handler(proxied)).status).toBe(200); + expect(sent).toEqual([job]); + }); + + 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("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 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: 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: URL, + body: jobs[0], + retries: 3, + }); + 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 new file mode 100644 index 0000000..874325b --- /dev/null +++ b/packages/mcp-toolkit/src/events/backends/qstash.ts @@ -0,0 +1,217 @@ +/** + * 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 type { Redis } from "@upstash/redis"; +import type { Client as QStashClient, Receiver } from "@upstash/qstash"; +import { + lazy, + nonRetryable, + readQStashJson, + resolveQStash, + resolveReceiver, + resolveRedis, +} from "../../shared/clients.js"; +import type { + DeliveryJob, + EventDelivery, + SendJob, + Subscription, + SubscriptionStore, +} from "../types.js"; + +/** 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`. */ + redis?: Redis; + /** Key prefix. Defaults to `mcp-events:`. */ + prefix?: string; + /** Set `false` to skip this package's tag in the telemetry header. */ + enableTelemetry?: boolean; +}; + +/** + * 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; + private readonly redis: () => Redis; + + constructor(config: RedisSubscriptionStoreConfig = {}) { + this.prefix = config.prefix ?? "mcp-events:"; + this.redis = lazy(() => + resolveRedis("RedisSubscriptionStore", config.redis, config.enableTelemetry), + ); + } + + async put(subscription: Subscription, options: { limit: number }): Promise { + const now = Date.now(); + const ttl = subscription.expiresAt - now; + 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 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 { + const ids = await this.redis().zrange(this.indexKey(event), Date.now(), "+inf", { + byScore: true, + }); + const found: Subscription[] = []; + 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); + } + } + return found; + } + + private subKey(id: string): string { + return `${this.prefix}sub:${id}`; + } + + 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 { + 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 the route serving `events.createDeliveryHandler()`. */ + url: string; + /** The QStash client. Defaults to one from `QSTASH_TOKEN` (and `QSTASH_URL`). */ + qstash?: QStashClient; + /** + * Verifies deliveries. Defaults to one from the `QSTASH_*_SIGNING_KEY` env vars. 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; +}; + +/** 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; + private readonly receiver: () => Receiver; + + constructor(config: QStashDeliveryConfig) { + this.config = config; + this.qstash = lazy(() => + resolveQStash("QStashDelivery", config.qstash, config.enableTelemetry), + ); + this.receiver = lazy(() => resolveReceiver("QStashDelivery", config.receiver)); + } + + 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: DELIVERY_RETRIES, + })), + ); + } + } + + /** + * The delivery endpoint. + * + * - **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(send: SendJob): (request: Request) => Promise { + return async (request: Request): Promise => { + // 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 new file mode 100644 index 0000000..985607d --- /dev/null +++ b/packages/mcp-toolkit/src/events/core.test.ts @@ -0,0 +1,810 @@ +/** + * 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 { afterEach, describe, expect, it, vi } from "vitest"; +import * as z from "zod"; +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")}`; + +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, + }; + return new Request("http://localhost/mcp", { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", id, method, params: { ...params, _meta: meta } }), + }); +} + +/** 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; +}; + +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); + 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 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, + secretKey: TEST_SECRET_KEY, + principal: userIdOf, + ...layerOptions, + }); + + const commentCreated = events.define("comment.created", { + description: "A new comment on a document.", + 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, 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" }); + events.register(server); + return server; + }); + + let id = 0; + 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; + }; + + const subscribe = async ( + args: Record, + opts: { + user?: string | null; + 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 }; + }; + + 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", () => { + 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" }, + 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(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 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); + } + }); + + 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 () => { + 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({ documentId: 42 }))).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("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 ** 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(30 * day); + expect(await ttlOf(long.result?.id)).toBeGreaterThan(29 * day); + expect(await ttlOf(unlimited.result?.id)).toBeGreaterThan(29 * day); + }); +}); + +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" }); + 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("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( + { documentId: "d", author: "a", text: "x" }, + { eventId: "deploy_42" }, + ); + 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 () => { + 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, drops 413 and redirects, and retries the rest", async () => { + let status = 410; + 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(done()).toEqual([true]); + expect(await store.get(String(sub.result?.id))).toBeNull(); + + await subscribe({ documentId: "d" }); + 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. + expect(done()).toEqual([true, true, false, true, false]); + }); + + it("stops delivering after unsubscribe", async () => { + const { subscribe, unsubscribe, commentCreated, receiver } = setup(); + const sub = await subscribe({ documentId: "d" }, { user: "alice" }); + 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 () => { + 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("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")).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); + 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 }); + } + }); + + 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, done, 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]); + // A refusal is final: retrying would be refused again. + expect(done()).toEqual([true, true]); + }); + + 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: "d", + payload: z.object({ text: z.string() }), + authorize: (_args, caller) => { + seen.push({ phase: caller.phase, hasAuth: caller.auth !== undefined }); + return true; + }, + }); + 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", hasAuth: true }, + { phase: "deliver", hasAuth: false }, + ]); + }); + + 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("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(); + expect(() => + events.define("open", { description: "d", payload: z.object({}) } as never), + ).toThrow(/authorize/); + }); + + 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("routes an emit by the payload's values", async () => { + const { subscribe, commentCreated, receiver } = setup(); + const sub = await subscribe({ documentId: "doc_9" }); + 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("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(); + 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 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; + 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("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 }) => { + if (!request) throw new Error("no session"); + return "session-user"; + }, + }); + const sub = await subscribe({ documentId: "d" }, { user: null }); + expect(sub.error).toBeUndefined(); + expect((await store.get(String(sub.result?.id)))?.subscriber).toBe("session-user"); + }); + + it("is required", () => { + expect(() => + createEventLayer({ + store: new MemorySubscriptionStore(), + delivery: new InlineDelivery(), + secretKey: TEST_SECRET_KEY, + } as unknown as EventLayerOptions), + ).toThrow(/principal/); + }); +}); + +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(); + }); + + 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/); + }); + + 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(); + }); + + 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) }, + { limit: Infinity }, + ); + + await commentCreated.emit({ documentId: "d", author: "a", text: "x" }); + expect(receiver.deliveries()).toHaveLength(0); + expect(done()).toEqual([true]); + }); +}); + +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", + // Trailing dots resolve like the bare name. + "https://localhost./x", + "https://foo.internal./x", + "https://x.local./x", + // IPv4 written another way: the URL parser normalizes it to a literal, which is refused. + "https://0x7f000001/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://receiver.example.com./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..ae10660 --- /dev/null +++ b/packages/mcp-toolkit/src/events/core.ts @@ -0,0 +1,505 @@ +/** + * MCP Events for servers on the official TypeScript SDK: typed event definitions, the three + * `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 AuthInfo, type McpServer } from "@modelcontextprotocol/server"; +import * as z from "zod"; +import { + callerOf, + requirePrincipal, + resolvePrincipal, + 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, SubscriptionStore } from "./types.js"; +import { SecretBox, callbackUrlProblem, decodeSecret, postSigned, readCapped } from "./webhooks.js"; + +/** The draft's cap on a delivered envelope. */ +const MAX_PAYLOAD_BYTES = 256 * 1024; +/** `CallbackEndpointError`: the callback failed verification. */ +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; +/** 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; + +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; + /** 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. */ +export type EventInputSchema = z.ZodObject; + +/** What `authorize` is told about the subscriber. */ +export type AuthorizeCaller = { + /** The subscriber's id, from `principal`. */ + principal: string; + /** `"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?: AuthInfo; + /** Only at subscribe time. */ + request?: Request; +}; + +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() })`. 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 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: Partial>, + caller: AuthorizeCaller, + ) => boolean | Promise; +}; + +export type EventHandle = { + readonly name: string; + /** + * 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`) and the host drops the repeat as a duplicate. + */ + emit(payload: z.input, options?: { eventId?: string }): Promise<{ eventId: string }>; +}; + +export type EventLayer = { + /** 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, + >( + name: string, + 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; +}; + +type Definition = { + config: EventConfig; + input: EventInputSchema; + /** What `events/list` returns for this event. */ + descriptor: Record; +}; + +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 } = 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. + // 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 ?? 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", + ); + } + return (box = new SecretBox(secretKey)); + }; + + function define( + name: string, + 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; + 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, + 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?.eventId) }; + } + + async function emit( + name: string, + rawPayload: unknown, + eventId = `evt_${randomHex(12)}`, + ): Promise<{ eventId: string }> { + const definition = definitions.get(name); + if (!definition) throw new Error(`Unknown event "${name}"`); + const payload = definition.config.payload.parse(rawPayload); + const envelope: EventEnvelope = { + eventId, + name, + timestamp: new Date().toISOString(), + data: payload, + cursor: null, + }; + const bytes = new TextEncoder().encode(JSON.stringify(envelope)).length; + if (bytes > MAX_PAYLOAD_BYTES) { + throw new Error(`Event envelope is ${bytes} bytes; the limit is ${MAX_PAYLOAD_BYTES}.`); + } + 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 }))); + } + return { eventId }; + } + + async function send(job: DeliveryJob): Promise { + const sub = await store.get(job.subscriptionId); + const definition = sub && definitions.get(sub.event); + 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( + projection(definition, sub.event, job.envelope.data) as never, + { principal: sub.subscriber, phase: "deliver" }, + ); + } catch { + return false; + } + 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({ + url: sub.url, + secret, + webhookId: job.envelope.eventId, + body: JSON.stringify(job.envelope), + subscriptionId: sub.id, + }); + } catch { + return false; + } + // Release the connection: the body is never needed. + await response.body?.cancel().catch(() => undefined); + 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") { + 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 (!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 || !secret) { + throw invalid(problem ?? "callback URL is missing", "invalid_callback_url"); + } + + 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(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; + if (!verified) await verifyCallback(url, secret, id); + + 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; + 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(), + cursor: null, + truncated: false, + }; + } + + async function unsubscribe(params: z.output, ctx: unknown) { + 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; + const id = await subscriptionId(subscriber, params.delivery.url, params.name, args); + await store.delete({ id, event: params.name, subscriber }); + } + 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 = randomBase64Url(24); + let detail: string | undefined; + try { + const response = await postSigned({ + url, + secret, + webhookId: `msg_verification_${randomHex(12)}`, + body: JSON.stringify({ type: "verification", challenge }), + subscriptionId: id, + }); + const text = await readCapped(response, MAX_CHALLENGE_RESPONSE_BYTES); + if (!response.ok) detail = `HTTP ${response.status}`; + else if (echoedChallenge(text) !== challenge) detail = "challenge not echoed"; + } catch (error) { + detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + } + if (detail !== undefined) { + console.warn(`[mcp-toolkit] callback verification failed for ${url}: ${detail}`); + throw new ProtocolError(CALLBACK_ENDPOINT_ERROR, "Callback URL failed verification"); + } + } + + 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 }, subscribe); + low.setRequestHandler("events/unsubscribe", { params: unsubscribeParams }, unsubscribe); + } + + return { define, register, createDeliveryHandler: () => delivery.createDeliveryHandler(send) }; +} + +/** 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; +} + +/** Deterministic, as the draft asks: same subscriber, callback, event and arguments give the same id. */ +export async function subscriptionId( + subscriber: string, + url: string, + event: string, + args: Record, +): Promise { + return `sub_${(await sha256Hex(canonicalJson([subscriber, url, event, args]))).slice(0, 32)}`; +} + +/** + * 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) : {}; + 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. */ +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 { + return undefined; + } +} + +function jsonSchema(schema: z.ZodType, io: "input" | "output"): Record { + const { $schema: _ignored, ...rest } = z.toJSONSchema(schema, { io }) as Record; + return rest; +} + +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", + "not_authenticated", + ); +} diff --git a/packages/mcp-toolkit/src/events/index.ts b/packages/mcp-toolkit/src/events/index.ts new file mode 100644 index 0000000..57c33ec --- /dev/null +++ b/packages/mcp-toolkit/src/events/index.ts @@ -0,0 +1,26 @@ +/** + * `@upstash/mcp-toolkit/events`: MCP Events (webhook delivery) for servers on the official + * TypeScript SDK. The Upstash backends are in `@upstash/mcp-toolkit/upstash`. + */ +export { + createEventLayer, + type AuthorizeCaller, + type EventConfig, + type EventHandle, + type EventInputSchema, + type EventLayer, + type EventLayerOptions, +} from "./core.js"; + +export type { Caller, PrincipalResolver } from "../shared/auth.js"; + +export type { + DeliveryJob, + EventDelivery, + EventEnvelope, + SendJob, + Subscription, + SubscriptionStore, +} from "./types.js"; + +export { verifyWebhook } from "./webhooks.js"; diff --git a/packages/mcp-toolkit/src/events/types.ts b/packages/mcp-toolkit/src/events/types.ts new file mode 100644 index 0000000..1c4cec2 --- /dev/null +++ b/packages/mcp-toolkit/src/events/types.ts @@ -0,0 +1,73 @@ +/** + * 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, 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; + name: string; + /** ISO-8601. */ + timestamp: string; + /** Matches the event's `payloadSchema`. */ + data: TData; + /** Replay cursor. This runtime does not replay, so always `null`. */ + cursor: string | null; +}; + +/** One webhook subscription, as stored. */ +export type Subscription = { + /** Derived from the subscriber, callback URL, event name and arguments. */ + id: string; + event: string; + /** The validated subscription arguments: an event matches when its payload has these values. */ + args: Record; + /** 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; + /** ISO-8601. */ + createdAt: string; + /** Epoch milliseconds. */ + expiresAt: number; +}; + +/** Durable storage for subscriptions. The layer does the matching, so a store only indexes by event. */ +export interface SubscriptionStore { + /** + * 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; + /** Every live subscription to `event`. */ + find(event: string): Promise; +} + +/** One envelope for one subscription. */ +export type DeliveryJob = { + subscriptionId: string; + envelope: EventEnvelope; +}; + +/** + * 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 before resolving. */ + enqueue(jobs: DeliveryJob[]): 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 new file mode 100644 index 0000000..a8ad78e --- /dev/null +++ b/packages/mcp-toolkit/src/events/webhooks.ts @@ -0,0 +1,221 @@ +/** + * 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 { + constantTimeEqual, + fromBase64, + fromUtf8, + randomBytes, + sha256, + toBase64, + utf8, +} from "../shared/crypto.js"; + +/** The secret length the MCP Events draft allows, in decoded bytes. */ +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 bytes = decodeBase64(secret.slice("whsec_".length)); + return bytes && bytes.length >= SECRET_MIN_BYTES && bytes.length <= SECRET_MAX_BYTES + ? bytes + : null; +} + +/** + * 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, + id: string, + body: string, + timestampSeconds = Math.floor(Date.now() / 1000), +): Promise> { + const key = decodeSecret(secret); + if (!key) throw new Error("Invalid webhook secret"); + 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,${toBase64(new Uint8Array(signature))}`, + }; +} + +/** Verifies a Standard Webhooks signature, for receivers and tests. */ +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 || !decodeSecret(secret)) return false; + const ts = Number(timestamp); + if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false; + const expected = (await signWebhook(secret, id, body, ts))["webhook-signature"]; + // Several space-separated signatures are allowed, for key rotation. + return signature.split(" ").some((candidate) => constantTimeEqual(candidate, expected)); +} + +type AesKey = Awaited>; + +/** + * Encrypts the hosts' signing secrets with a server-side key (AES-256-GCM), so a leaked database + * 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 ((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"]), + ); + } + + async seal(plaintext: string): Promise { + const iv = randomBytes(12); + const sealed = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + await this.key, + utf8(plaintext), + ); + return `v1.${toBase64(iv)}.${toBase64(new Uint8Array(sealed))}`; + } + + /** 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) 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, + * 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"; + 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"; + + // `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(/\.+$/, ""); + 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 name"; + } + return null; +} + +/** POSTs a signed JSON body to a callback, never following redirects. */ +export async function postSigned(options: { + url: string; + secret: string; + webhookId: string; + body: string; + subscriptionId: string; +}): Promise { + const headers = await signWebhook(options.secret, options.webhookId, options.body); + return await fetch(options.url, { + method: "POST", + redirect: "manual", + signal: AbortSignal.timeout(CALLBACK_TIMEOUT_MS), + headers: { + "content-type": "application/json", + "x-mcp-subscription-id": options.subscriptionId, + ...headers, + }, + body: options.body, + }); +} + +/** 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(); + 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); +} + +/** 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 new file mode 100644 index 0000000..bddae88 --- /dev/null +++ b/packages/mcp-toolkit/src/shared/auth.ts @@ -0,0 +1,55 @@ +import type { AuthInfo } from "@modelcontextprotocol/server"; + +/** What `principal` is handed for each call. */ +export type Caller = { + /** + * 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. + */ + request: Request | undefined; +}; + +/** + * 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) => string | Promise; + +/** The auth and request an SDK request handler context carries. */ +export function callerOf(context: unknown): Caller { + const http = (context as { http?: { authInfo?: AuthInfo; req?: Request } } | undefined)?.http; + return { auth: http?.authInfo, request: http?.req }; +} + +/** + * 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 { + try { + const id: unknown = await principal(caller); + return typeof id === "string" && id !== "" ? id : undefined; + } catch { + return undefined; + } +} + +/** 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\` that returns your user id (and throws when it can't). 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..10b3218 --- /dev/null +++ b/packages/mcp-toolkit/src/shared/clients.ts @@ -0,0 +1,112 @@ +/** + * 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"; +import { env } from "./env.js"; + +export { INTERNAL_ERROR, env } from "./env.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 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" } }); +} + +/** + * 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/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/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/qstash.test.ts b/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts new file mode 100644 index 0000000..c4b42fe --- /dev/null +++ b/packages/mcp-toolkit/src/tasks/backends/qstash.test.ts @@ -0,0 +1,339 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +import { QStashDispatcher, RedisTaskStore } from "./qstash.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: crypto.randomUUID(), + status: "working", + statusMessage: "Queued for durable execution", + createdAt: now, + lastUpdatedAt: now, + ttlMs: 300_000, + pollIntervalMs: 2_000, + name: "generate_report", + args: { topic: "coffee trends" }, + owner: "alice", + ...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).toEqual(task); + expect(typeof loaded?.statusMessage).toBe("string"); + }); + + it("returns null for an unknown task", async () => { + expect(await store.get(crypto.randomUUID())).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("patches only the fields it is given", async () => { + const task = makeTask(); + await store.create(task); + + 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 () => { + 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, and gets the + // current record back in the same round trip. + const cancelled = await store.settle(task.taskId, { status: "cancelled" }); + expect(cancelled?.status).toBe("completed"); + 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: {} }))?.status).toBe( + "cancelled", + ); + }); + + it("returns null when settling a task that does not exist", async () => { + expect(await store.settle(crypto.randomUUID(), { 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. + await store.update(task.taskId, { statusMessage: "Attempt failed: too late" }); + + 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 () => { + const ghost = crypto.randomUUID(); + await store.update(ghost, { statusMessage: "x" }); + 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(makeTask()), + ).rejects.toThrow(/QSTASH_TOKEN/); + }); +}); + +describe("QStashDispatcher.createExecuteHandler", () => { + const URL = "https://public.example.com/api/execute"; + + type Calls = { ran: string[]; failed: { taskId: string; error: TaskError }[] }; + + /** 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: URL, receiver: testReceiver() }); + const handler = dispatcher.createExecuteHandler({ + run: async (taskId: string) => { + calls.ran.push(taskId); + if (options.throws) throw new Error("boom"); + }, + fail: async (taskId: string, error: TaskError) => { + calls.failed.push({ taskId, error }); + }, + }); + return { handler, calls }; + }; + + /** 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 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 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 } = 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. + expect(calls.failed).toEqual([]); + }); + + it("settles the task failed when the failure callback arrives", async () => { + const { handler, calls } = connected(); + // The shape QStash posts once every retry is exhausted. + const response = await handler( + qstashRequest(URL, { + 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", + }); + }); + + 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 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 } = connected(); + await handler( + qstashRequest(URL, { + 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ı ✗" }); + }); +}); + +describe("QStashDispatcher.dispatch", () => { + it("publishes only the 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 }); + + const task = makeTask(); + await dispatcher.dispatch(task); + 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 new file mode 100644 index 0000000..3b77c47 --- /dev/null +++ b/packages/mcp-toolkit/src/tasks/backends/qstash.ts @@ -0,0 +1,247 @@ +/** 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 { + INTERNAL_ERROR, + lazy, + nonRetryable, + readQStashJson, + resolveQStash, + resolveReceiver, + resolveRedis, +} from "../../shared/clients.js"; +import { fromBase64, fromUtf8 } from "../../shared/crypto.js"; +import type { + Task, + TaskDispatcher, + TaskEndpoints, + TaskError, + TaskPatch, + TaskStore, + TerminalTaskStatus, +} from "../types.js"; + +/** + * 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. + */ +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. */ +const DEFAULT_RETRIES = 5; + +export type RedisTaskStoreConfig = { + /** + * 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. Defaults to `mcp:task:`. */ + prefix?: string; + /** Set `false` to skip this package's tag in the telemetry header. */ + enableTelemetry?: boolean; +}; + +/** + * 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 +if status ~= '"completed"' and status ~= '"failed"' and status ~= '"cancelled"' then + redis.call('HSET', KEYS[1], unpack(ARGV)) +end +return redis.call('HGETALL', KEYS[1]) +`; + +/** + * 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 redis: () => Redis; + + constructor(config: RedisTaskStoreConfig = {}) { + this.prefix = config.prefix ?? "mcp:task:"; + this.redis = lazy(() => resolveRedis("RedisTaskStore", config.redis, config.enableTelemetry)); + } + + 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 ? (fields as Task) : null; + } + + async update(taskId: string, patch: TaskPatch): Promise { + await this.guardedWrite(taskId, patch); + } + + async settle( + taskId: string, + patch: TaskPatch & { status: TerminalTaskStatus }, + ): Promise { + return await this.guardedWrite(taskId, patch); + } + + private key(taskId: string): string { + return this.prefix + taskId; + } + + 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)], + Object.entries(fields).flat(), + ); + 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; + } +} + +export type QStashDispatcherConfig = { + /** The public URL QStash delivers to: the route serving `tasks.createExecuteHandler()`. */ + url: string; + /** The QStash client. Defaults to one from `QSTASH_TOKEN` (and `QSTASH_URL`). */ + qstash?: QStashClient; + /** + * 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; +}; + +/** + * 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 config: QStashDispatcherConfig; + private readonly qstash: () => QStashClient; + private readonly receiver: () => Receiver; + + constructor(config: QStashDispatcherConfig) { + this.config = config; + this.qstash = lazy(() => + resolveQStash("QStashDispatcher", config.qstash, config.enableTelemetry), + ); + this.receiver = lazy(() => resolveReceiver("QStashDispatcher", config.receiver)); + } + + 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, + // Comes back to the same route once retries are exhausted, and settles the task `failed`. + failureCallback: this.config.url, + }); + } + + /** 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 + * (`sourceBody`) settles it `failed`. + * + * - **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(endpoints: TaskEndpoints): (request: Request) => Promise { + return async (request: Request): Promise => { + // 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(body); + if (failure) { + await endpoints.fail(failure.taskId, failure.error); + return new Response("recorded"); + } + if (typeof body.taskId !== "string") return nonRetryable("missing taskId"); + + try { + await endpoints.run(body.taskId, undefined); + return new Response("ok"); + } catch (error) { + console.error(`[mcp-toolkit] task ${body.taskId} failed, asking QStash to retry:`, error); + return new Response("retry", { status: 500 }); + } + }; + } +} + +/** Either body QStash posts to the execute endpoint. */ +type DeliveryBody = { + /** A delivery: the body we published. */ + taskId?: string; + /** A failure callback: base64 of the failed message's body. */ + sourceBody?: string; + /** The last response's status. */ + status?: number; + /** Base64 of the last response's body. */ + body?: string; + dlqId?: string; + retried?: number; + maxRetries?: number; +}; + +function readFailureCallback( + payload: DeliveryBody, +): { taskId: string; error: TaskError } | undefined { + if (typeof payload.sourceBody !== "string") return undefined; + let taskId: unknown; + try { + taskId = (JSON.parse(fromUtf8(fromBase64(payload.sourceBody))) as { taskId?: unknown }).taskId; + } catch { + return undefined; + } + if (typeof taskId !== "string") return undefined; + const response = + typeof payload.body === "string" ? fromUtf8(fromBase64(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})` : "" + }`, + data: { dlqId: payload.dlqId, status: payload.status, response }, + }, + }; +} + +/** + * 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 toFields(patch: Partial): Record { + const fields: Record = {}; + for (const [field, value] of Object.entries(patch)) { + if (value !== undefined) fields[field] = JSON.stringify(value); + } + return fields; +} diff --git a/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts b/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts new file mode 100644 index 0000000..470f872 --- /dev/null +++ b/packages/mcp-toolkit/src/tasks/backends/workflow.test.ts @@ -0,0 +1,315 @@ +/** + * 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 { 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, workflowRoute } from "./workflow.js"; +import { createTaskLayer } from "../core.js"; +import type { Task, TaskContext } from "../types.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: 60_000, + pollIntervalMs: 2_000, + name: "demo", + args: {}, + owner: "local", + ...overrides, +}); + +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("gives handlers the Workflow API without a type argument", () => { + const tasks = createTaskLayer({ + store: new MemoryTaskStore(), + dispatcher: new WorkflowDispatcher({ url: URL }), + 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, so a double dispatch is deduplicated", async () => { + const { client, triggered } = stubClient(); + const dispatcher = new WorkflowDispatcher({ url: URL, client }); + + await dispatcher.dispatch(task()); + + expect(triggered).toHaveLength(1); + expect(triggered[0]?.url).toBe(URL); + expect(triggered[0]?.body).toEqual({ taskId: "task-1" }); + expect(triggered[0]?.workflowRunId).toBe("task-1"); + }); + + it("cancels the run itself, not just the task record", async () => { + const { client, cancelled } = stubClient(); + 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"]); + }); +}); + +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 {} + } + + /** 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" }); + 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"); + } + + it("is just the task context when the transport adds nothing", async () => { + const settled = await runWith(undefined, async (task) => { + expect(typeof task.update).toBe("function"); + expect(typeof task.isCancelled).toBe("function"); + return { taskId: task.taskId }; + }); + + expect(settled?.status).toBe("completed"); + expect(settled?.result).toEqual({ taskId: "t1" }); + }); + + it("merges the transport's context in, keeping its prototype methods", async () => { + const workflow = new FakeWorkflowContext(); + + const settled = await runWith(workflow, async (task) => { + // Both halves on one object: ours by assignment, the engine's off the prototype. + await task.update("working on it"); + const value = await task.run("step-1", async () => "stepped"); + return { value }; + }); + + expect(workflow.ran).toEqual(["step-1"]); + expect(settled?.result).toEqual({ value: "stepped" }); + // The status update went through our half of the same object. + expect(settled?.status).toBe("completed"); + }); + + it("journals the SDK's own writes, so a replay does not rewind the status message", async () => { + const journaled: string[] = []; + const { dispatcher } = await layerWithTask(async (task) => { + await task.update("one"); + await task.update("two"); + return {}; + }); + + await dispatcher.run("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"]); + }); + + it("writes directly when the transport has no journal", async () => { + const settled = await runWith(undefined, async (task) => { + await task.update("progress"); + return {}; + }); + // A queue never replays, so an unjournaled write is the right thing there. + expect(settled?.status).toBe("completed"); + }); + + it("does not lose engine methods to a spread", async () => { + // Guards the merge strategy itself: `{ ...context }` would silently drop `run`, and the + // handler would fail only at runtime, on a real workflow. + const workflow = new FakeWorkflowContext(); + await runWith(workflow, async (task) => { + expect(Object.getPrototypeOf(task)).toBe(FakeWorkflowContext.prototype); + return {}; + }); + }); +}); + +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 }; + 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: 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", () => { + // 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: URL, + 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); + }); +}); + +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 new file mode 100644 index 0000000..08fdd3e --- /dev/null +++ b/packages/mcp-toolkit/src/tasks/backends/workflow.ts @@ -0,0 +1,152 @@ +/** + * 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 type { Client as QStashClient, Receiver } from "@upstash/qstash"; +import { Client as WorkflowClient, serve, type WorkflowContext } from "@upstash/workflow"; +import { + INTERNAL_ERROR, + boundToUrl, + env, + lazy, + requireEnv, + resolveQStash, + resolveReceiver, +} from "../../shared/clients.js"; +import { addQStashTelemetry } from "../../telemetry.js"; +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; + /** 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; + /** 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; +}; + +type WorkflowPayload = { taskId?: string }; + +/** + * 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 config: WorkflowDispatcherConfig; + private readonly client: () => WorkflowClient; + + constructor(config: WorkflowDispatcherConfig) { + 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; + }); + } + + async dispatch(task: Task): Promise { + await this.client().trigger({ + url: this.config.url, + body: { taskId: task.taskId } satisfies WorkflowPayload, + retries: this.config.retries, + // One run per task: Workflow refuses a run id that was already used. + workflowRunId: task.taskId, + }); + } + + 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( + 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( + 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( + "WorkflowDispatcher", + this.config.qstash, + this.config.enableTelemetry, + ); + 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`. + */ +function journalFor(context: WorkflowContext): TaskJournal { + return async (name, fn) => (insideStep(context) ? await fn() : await context.run(name, fn)); +} + +/** + * 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; + return executor === undefined || Boolean(executor.executingStep); +} diff --git a/packages/mcp-toolkit/src/tasks/core.test.ts b/packages/mcp-toolkit/src/tasks/core.test.ts new file mode 100644 index 0000000..6cdb4b7 --- /dev/null +++ b/packages/mcp-toolkit/src/tasks/core.test.ts @@ -0,0 +1,628 @@ +/** + * The runtime, exercised through a real `McpServer` and a real transport — the requests below are + * genuine JSON-RPC `tools/call`s over the wire, not direct calls into the layer. + */ +import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; +import { afterEach, describe, expect, it } from "vitest"; +import * as z from "zod"; +import { createTaskLayer } from "./core.js"; +import type { TaskContext, TaskLayer, TaskToolConfig, WireTask } from "./index.js"; +import { + InlineDispatcher, + ManualDispatcher, + MemoryTaskStore, + sleep, + userIdOf, +} from "../test-support.js"; + +const PROTOCOL_VERSION = "2026-07-28"; + +type ToolResult = { + content?: { type: string; text?: string }[]; + structuredContent?: Record; + isError?: boolean; +}; + +/** `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?: User) => Promise; + status: (taskId: string, user?: User) => Promise; + cancel: (taskId: string, user?: User) => Promise; + listTools: () => Promise; + tasks: TaskLayer; + store: MemoryTaskStore; + dispatcher: InlineDispatcher; + close: () => Promise; +}; + +type ReportArgs = { topic: string }; + +/** Every test identifies callers by the user id their auth carries. */ +const principal = userIdOf; + +/** Builds a server with one task tool backed by `handler`. */ +async function harness( + handler: (args: ReportArgs, task: TaskContext) => Promise>, + layer: Partial[0]> = {}, + tool: Partial>> = {}, +): Promise { + const store = new MemoryTaskStore(); + 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( + "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.register(server); + return server; + }); + + let id = 0; + 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", + "mcp-protocol-version": PROTOCOL_VERSION, + "mcp-method": method, + }; + if (typeof params.name === "string") headers["mcp-name"] = params.name; + const response = await handlerFor.fetch( + new Request("http://localhost/mcp", { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id: ++id, + method, + params: { + ...params, + _meta: { + "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, + "io.modelcontextprotocol/clientInfo": { name: "test", version: "1.0.0" }, + // 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, + ); + 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 { + 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, + close: async () => { + await handlerFor.close(); + store.clear(); + }, + }; +} + +/** A four-step handler that cooperates with cancellation, like the demo's. */ +const steppedHandler = + (steps = 4, stepMs = 20) => + 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}`); + await sleep(stepMs); + } + 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 () => { + await live?.close(); + live = undefined; + }); + + it("registers the task tool plus the shared status and cancel tools, once", async () => { + live = await harness(steppedHandler()); + expect((await live.listTools()).sort()).toEqual( + ["generate_report", "task_cancel", "task_status"].sort(), + ); + }); + + 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 }] }), + ); + coldInstance.createExecuteHandler(); + const now = new Date().toISOString(); + const taskId = crypto.randomUUID(); + await store.create({ + taskId, + status: "working", + createdAt: now, + lastUpdatedAt: now, + ttlMs: 60_000, + pollIntervalMs: 2_000, + name: "generate_report", + args: { topic: "ferries" }, + owner: "alice", + }); + 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 InlineDispatcher(), + 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" }); + + 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 taskId = await live.start(); + // No awaiting, no sleeping: the create must have committed before the response went out. + const stored = await live.store.get(taskId); + expect(stored?.name).toBe("generate_report"); + expect(stored?.args).toEqual({ topic: "coffee trends" }); + }); + + it("returns the handler's own result content from task_status once completed", async () => { + live = await harness(steppedHandler()); + const taskId = await live.start(); + await live.dispatcher.drain(); + + 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 taskId = await live.start(); + + await sleep(50); + 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("answers an unknown task id with a tool error, not a protocol error", async () => { + live = await harness(steppedHandler()); + 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)); + const taskId = await live.start(); + + await sleep(70); + const cancelled = await live.cancel(taskId); + expect(cancelled.structuredContent?.status).toBe("cancelled"); + + await live.dispatcher.drain(); + // 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 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 () => { + live = await harness(async (_args, task) => { + await live!.store.settle(task.taskId, { status: "cancelled" }); + return { content: [{ type: "text", text: "too late" }] }; + }); + const taskId = await live.start("x"); + await live.dispatcher.drain(); + + const after = await live.status(taskId); + expect(after.structuredContent?.status).toBe("cancelled"); + expect(after.structuredContent?.result).toBeUndefined(); + }); + }); + + 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 userIdOf({ auth }); + }, + }); + 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"); + 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)); + 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, null)).isError).toBe(true); + await live.dispatcher.drain(); + }); + + 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).toHaveLength(0); + + const taskId = await live.start("x", "alice"); + 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("refuses when principal throws, or returns no usable id at runtime", async () => { + 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, + }); + 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).toHaveLength(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"); + 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(); + live.store.seed({ ...record!, owner: undefined as unknown as string }); + 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({ + store: new MemoryTaskStore(), + dispatcher: new InlineDispatcher(), + } as unknown as Parameters[0]), + ).toThrow(/principal/); + }); + }); + + 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", () => { + it("ignores a redelivery of a task that already finished", async () => { + let runs = 0; + 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 manual.run(taskId); + // The same message arriving twice is the contract, not a bug. + await manual.run(taskId); + await manual.run(taskId); + expect(runs).toBe(1); + }); + + it("acknowledges a delivery for a task that no longer exists", async () => { + 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: manual as never }, + ); + const taskId = await live.start("x"); + // 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(manual.run(taskId)).rejects.toThrow("boom 2"); + await manual.run(taskId); + expect((await live.status(taskId)).structuredContent?.status).toBe("completed"); + expect(attempts).toBe(3); + }); + + it("settles failed once the dispatcher stops retrying", async () => { + live = await harness(async () => { + throw new Error("permanent"); + }); + const taskId = await live.start("x"); + await live.dispatcher.drain(); + + 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("fails the task when it cannot be dispatched, instead of leaving it working", async () => { + const manual = new ManualDispatcher(); + manual.failNext = new Error("queue unavailable"); + 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(await live.store.get(created!)).toMatchObject({ + status: "failed", + statusMessage: "Could not be queued", + }); + }); + }); + + it("stops pending deliveries when a task is cancelled, and only then", async () => { + const manual = new ManualDispatcher(); + 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([]); + + const taskId = await live.start("y"); + await live.cancel(taskId); + 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 }, + }); + const result = await live.call("generate_report", { topic: "x" }); + expect(result.structuredContent).toMatchObject({ ttlMs: 60_000, pollIntervalMs: 5_000 }); + 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. + live = await harness(async (args) => ({ + content: [{ type: "text", text: args.topic.toUpperCase() }], + })); + const taskId = await live.start("coffee"); + await live.dispatcher.drain(); + expect((await live.status(taskId)).content?.at(-1)?.text).toBe("COFFEE"); + }); +}); + +describe("wire shape", () => { + it("keeps a WireTask assignable from what task_status returns", () => { + const wire: WireTask = { + taskId: "t", + status: "working", + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + ttlMs: 300_000, + pollIntervalMs: 2_000, + }; + expect(wire.ttlMs).toBe(300_000); + }); +}); diff --git a/packages/mcp-toolkit/src/tasks/core.ts b/packages/mcp-toolkit/src/tasks/core.ts new file mode 100644 index 0000000..6871ea8 --- /dev/null +++ b/packages/mcp-toolkit/src/tasks/core.ts @@ -0,0 +1,371 @@ +/** + * 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 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"; +import type { + Task, + TaskContext, + TaskDispatcher, + TaskError, + TaskJournal, + TaskStore, + WireTask, +} from "./types.js"; + +/** 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"; + +export type TaskLayerOptions = { + store: TaskStore; + dispatcher: TaskDispatcher; + /** + * 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"`. + */ + principal: PrincipalResolver; + defaults?: { + /** Retention window from creation. Defaults to 1 day. */ + ttlMs?: number; + /** Poll interval suggested to the model. Defaults to 2s. */ + pollIntervalMs?: number; + }; +}; + +export type TaskToolConfig = { + title?: string; + /** What the tool does. The layer appends a sentence about polling `task_status`. */ + description: string; + /** A Standard Schema (Zod 4, ArkType, Valibot) for the arguments. */ + 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 { + readonly "~standard": { types?: { readonly output: infer Output } | undefined }; +} + ? Output + : unknown; + +/** + * 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 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>, + ): 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; +}; + +/** + * Builds a tasks runtime over a store and a dispatcher. + * + * ```ts + * 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 } = options; + const principal = requirePrincipal(options.principal, "createTaskLayer"); + 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, caller: Caller = callerOf(context)) => + resolvePrincipal(principal, caller); + + function define( + name: string, + config: TaskToolConfig, + handler: TaskHandler, TContext>, + ): 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, + }); + } + + function register(server: McpServer): void { + for (const [name, { config }] of definitions) { + server.registerTool( + name, + { + 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, + ); + } + + const inputSchema = z.object({ + taskId: z.uuid().describe("The taskId returned when the task was started."), + }); + + server.registerTool( + STATUS_TOOL, + { + 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 caller = await callerId(context); + if (!caller) return errorResult(NOT_AUTHENTICATED); + const task = await owned(taskId, caller); + return task ? statusResult(task) : errorResult(unknownTask(taskId)); + }) as never, + ); + + server.registerTool( + CANCEL_TOOL, + { + 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) => { + 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 startTask(name: string, args: unknown, context: unknown) { + 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(), + 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 { + 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 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 || task.status !== "working") return; + const definition = definitions.get(task.name); + if (!definition) { + throw new Error( + `No task handler defined for "${task.name}". Call tasks.define(...) at module scope, in a module the execute route imports.`, + ); + } + + // Journaled writes are named by call order, which a replay reproduces. + 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()); + }, + isCancelled: async () => (await store.get(taskId))?.status !== "working", + }; + + const result = await definition.handler(task.args, mergeContext(taskContext, context)); + // A cancel that landed meanwhile wins: settle refuses the second terminal write. + await store.settle(taskId, { + status: "completed", + statusMessage: definition.config.completedMessage ?? "Completed", + result, + }); + } + + 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 store.get(taskId); + return task && task.owner === caller ? task : null; + } + + return { + define, + register, + createExecuteHandler: () => dispatcher.createExecuteHandler({ run, fail }), + }; +} + +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.`; + +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 line = `Task ${task.taskId} is ${task.status}${task.statusMessage ? `: ${task.statusMessage}` : "."}`; + 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) => `${Math.max(1, Math.round(ms / 1000))}s`; + +/** + * 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, + 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 what the model sees in `structuredContent`. */ +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 new file mode 100644 index 0000000..f918107 --- /dev/null +++ b/packages/mcp-toolkit/src/tasks/index.ts @@ -0,0 +1,28 @@ +/** + * `@upstash/mcp-toolkit/tasks`: long-running tools for MCP servers on the official TypeScript SDK. + * The Upstash backends are in `@upstash/mcp-toolkit/upstash`. + */ +export { + createTaskLayer, + type TaskHandler, + type TaskLayer, + type TaskLayerOptions, + type TaskToolConfig, + type TaskAuthorizeCaller, +} from "./core.js"; + +export type { Caller, PrincipalResolver } from "../shared/auth.js"; + +export type { + Task, + TaskContext, + TaskDispatcher, + TaskEndpoints, + TaskError, + TaskJournal, + TaskPatch, + TaskStatus, + TaskStore, + TerminalTaskStatus, + WireTask, +} from "./types.js"; diff --git a/packages/mcp-toolkit/src/tasks/types.ts b/packages/mcp-toolkit/src/tasks/types.ts new file mode 100644 index 0000000..949fabb --- /dev/null +++ b/packages/mcp-toolkit/src/tasks/types.ts @@ -0,0 +1,105 @@ +/** + * 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 states, from the `io.modelcontextprotocol/tasks` extension. The last three are terminal. */ +export type TaskStatus = "working" | "completed" | "failed" | "cancelled"; + +export type TerminalTaskStatus = Exclude; + +/** A JSON-RPC error, as carried by a `failed` task. */ +export type TaskError = { + code: number; + message: string; + data?: unknown; +}; + +/** What the model sees in `structuredContent`: the extension's task object. */ +export type WireTask = { + taskId: string; + status: TaskStatus; + statusMessage?: string; + /** ISO-8601. */ + createdAt: string; + /** ISO-8601. */ + lastUpdatedAt: string; + /** Retention window in milliseconds, from creation. */ + ttlMs: number; + pollIntervalMs: number; + /** The tool result, once `completed`. */ + result?: Record; + /** Once `failed`. */ + error?: TaskError; +}; + +/** The stored task: the wire object plus server-only fields the model never sees. */ +export type Task = WireTask & { + /** The defined task name, which picks the handler. */ + name: string; + /** The validated tool input. */ + args: unknown; + /** The caller that started the task. Only they can read or cancel it. */ + owner: string; +}; + +/** The fields a write may change. */ +export type TaskPatch = Partial>; + +export interface TaskStore { + /** 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 no-op on a terminal or missing task. */ + update(taskId: string, patch: TaskPatch): Promise; + + /** + * 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: TaskPatch & { status: TerminalTaskStatus }): Promise; +} + +/** + * 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 a delivery. The layer calls it once per task. */ + dispatch(task: Task): Promise; + + /** Stops pending deliveries, when the transport can. Idempotent. */ + cancel(taskId: string): 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'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; + /** Records a terminal failure, once the transport has stopped retrying. */ + fail(taskId: string, error: TaskError): Promise; +}; + +/** 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 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. */ + isCancelled(): Promise; +}; 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 new file mode 100644 index 0000000..2ef8261 --- /dev/null +++ b/packages/mcp-toolkit/src/telemetry.ts @@ -0,0 +1,87 @@ +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-toolkit@${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-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. + */ +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 + } +}; + +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 new file mode 100644 index 0000000..f1b5be1 --- /dev/null +++ b/packages/mcp-toolkit/src/test-support.ts @@ -0,0 +1,315 @@ +/** + * Test-only helpers and in-memory backends (never imported by an entry point). + * + * 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 { createHash, createHmac, randomUUID } from "node:crypto"; +import { config } from "dotenv"; +import { Receiver } from "@upstash/qstash"; +import { Redis } from "@upstash/redis"; +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(); + +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-toolkit:${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)); + +/** 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. 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; + protected endpoints: TaskEndpoints | undefined; + + async dispatch(task: Task): Promise { + if (this.failNext) { + const error = this.failNext; + this.failNext = undefined; + throw error; + } + this.dispatched.push(task); + } + + 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.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.connected().fail(taskId, error); + } + + protected connected(): TaskEndpoints { + if (!this.endpoints) throw new Error("Call tasks.createExecuteHandler() first"); + return this.endpoints; + } +} + +/** + * 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, 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 { + 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 new file mode 100644 index 0000000..31c6bbd --- /dev/null +++ b/packages/mcp-toolkit/src/upstash.ts @@ -0,0 +1,19 @@ +/** + * `@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, + type RedisTaskStoreConfig, + type QStashDispatcherConfig, +} from "./tasks/backends/qstash.js"; + +export { WorkflowDispatcher, type WorkflowDispatcherConfig } from "./tasks/backends/workflow.js"; + +export { + RedisSubscriptionStore, + QStashDelivery, + type RedisSubscriptionStoreConfig, + type QStashDeliveryConfig, +} from "./events/backends/qstash.js"; diff --git a/packages/mcp-toolkit/src/version.ts b/packages/mcp-toolkit/src/version.ts new file mode 100644 index 0000000..5df3807 --- /dev/null +++ b/packages/mcp-toolkit/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.0.0"; diff --git a/packages/mcp-toolkit/tsconfig.json b/packages/mcp-toolkit/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/mcp-toolkit/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/mcp-toolkit/tsup.config.ts b/packages/mcp-toolkit/tsup.config.ts new file mode 100644 index 0000000..97f1398 --- /dev/null +++ b/packages/mcp-toolkit/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + "tasks/index": "src/tasks/index.ts", + "events/index": "src/events/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 2b2e57d..bea7a4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,49 @@ importers: specifier: 7.0.2 version: 7.0.2 + examples/mcp-toolkit-demo: + dependencies: + '@modelcontextprotocol/server': + specifier: ^2.0.0 + version: 2.3.1 + '@upstash/mcp-toolkit': + specifier: workspace:* + version: link:../../packages/mcp-toolkit + '@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) + 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 + examples/tanstack-ai-demo: dependencies: '@tanstack/ai': @@ -367,6 +410,31 @@ importers: specifier: 7.0.2 version: 7.0.2 + packages/mcp-toolkit: + dependencies: + '@upstash/qstash': + specifier: ^2.11.3 + version: 2.12.0 + '@upstash/redis': + specifier: ^1.38.4 + 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 + devDependencies: + '@modelcontextprotocol/client': + specifier: ^2.0.0 + version: 2.3.1 + '@modelcontextprotocol/server': + specifier: ^2.0.0 + version: 2.3.1 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + packages/sdk: dependencies: '@upstash/ratelimit': @@ -922,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==} @@ -1079,6 +1131,18 @@ packages: '@mermaid-js/parser@1.1.1': resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@modelcontextprotocol/client@2.3.1': + resolution: {integrity: sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.3.1': + resolution: {integrity: sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==} + engines: {node: '>=20'} + + '@modelcontextprotocol/server@2.3.1': + resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==} + engines: {node: '>=20'} + '@next/env@16.2.6': resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} @@ -1114,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==} @@ -2110,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==} @@ -2202,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==} @@ -2419,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==} @@ -2954,6 +2987,9 @@ packages: resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==} engines: {node: '>=16.0.0'} + '@upstash/qstash@2.12.0': + resolution: {integrity: sha512-LRFuMAqdDlVtCnDRLS00DM4lsE6JeP1A696lUcuUdWEXM9Sb1lhKYSuK2gHkG5VBxreR4QJNs909AGabAfSRdg==} + '@upstash/ratelimit@2.0.8': resolution: {integrity: sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==} peerDependencies: @@ -2962,6 +2998,11 @@ packages: '@upstash/redis@1.38.4': resolution: {integrity: sha512-ZX69mKReun/yieyHYLoBg0eSiF6hjCQjd8zUKkJ5eCHehDV6P8S8oZOHxGSfkH1spfOhGZk0EGerBrfUsXow8g==} + '@upstash/workflow@1.4.0': + resolution: {integrity: sha512-IZHVIq/p3zsKrR7vh6Vl4MZJ6S4KYHXP8iU5w028nX2SWvMbjQ6RE78LzKvjprvTn96CM3u652yES1kMaLsz3g==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@vercel/cli-config@0.2.0': resolution: {integrity: sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ==} @@ -3596,6 +3637,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'} @@ -3908,6 +3953,9 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -3990,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==} @@ -4337,6 +4381,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'} @@ -5824,6 +5872,25 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/client@2.3.1': + dependencies: + '@modelcontextprotocol/core': 2.3.1 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + jose: 6.2.12 + pkce-challenge: 5.0.1 + zod: 4.4.3 + + '@modelcontextprotocol/core@2.3.1': + dependencies: + zod: 4.4.3 + + '@modelcontextprotocol/server@2.3.1': + dependencies: + '@modelcontextprotocol/core': 2.3.1 + zod: 4.4.3 + '@next/env@16.2.6': {} '@next/env@16.2.9': {} @@ -7530,6 +7597,12 @@ snapshots: dependencies: '@upstash/redis': 1.38.4 + '@upstash/qstash@2.12.0': + dependencies: + jose: 5.10.0 + neverthrow: 7.2.0 + uncrypto: 0.1.3 + '@upstash/ratelimit@2.0.8(@upstash/redis@1.38.4)': dependencies: '@upstash/core-analytics': 0.0.10 @@ -7539,6 +7612,11 @@ snapshots: dependencies: uncrypto: 0.1.3 + '@upstash/workflow@1.4.0(zod@4.4.3)': + dependencies: + '@upstash/qstash': 2.12.0 + zod: 4.4.3 + '@vercel/cli-config@0.2.0': dependencies: xdg-app-paths: 5.5.1 @@ -8208,6 +8286,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 @@ -8567,6 +8649,8 @@ snapshots: jose@5.10.0: {} + jose@6.2.12: {} + joycon@3.1.1: {} js-yaml@3.14.2: @@ -9196,6 +9280,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 eb99153..7b21b5f 100644 --- a/scripts/sync-version.mjs +++ b/scripts/sync-version.mjs @@ -19,6 +19,7 @@ const TARGETS = { "packages/eve": "src/version.ts", "packages/eve-extension": "extension/lib/version.ts", "packages/tanstack-ai": "src/version.ts", + "packages/mcp-toolkit": "src/version.ts", }; const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");