From 62eeb6850db4b8a04024dde8a976fb30ca3de0d9 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Wed, 7 Oct 2026 12:21:26 +0000 Subject: [PATCH 01/16] DX-3022: docs: add MCP Tasks under AgentKit and the QStash TS SDK One page, redis/sdks/agentkit/mcp-tasks, for @upstash/mcp-tasks: durable long-running MCP tools on Redis and QStash/Workflow. It sits next to the other AgentKit packages in both AgentKit groups. The QStash TS SDK sidebar gets a stub at qstash/sdks/ts/mcp-tasks that redirects to it (same pattern as redis/search/command-reference). llms.txt and llms-full.txt regenerated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd --- docs.json | 11 +- llms-full.txt | 541 ++++++++++++++++++++++++++++++ llms.txt | 3 + qstash/sdks/ts/mcp-tasks.mdx | 9 + redis/sdks/agentkit/mcp-tasks.mdx | 269 +++++++++++++++ 5 files changed, 831 insertions(+), 2 deletions(-) create mode 100644 qstash/sdks/ts/mcp-tasks.mdx create mode 100644 redis/sdks/agentkit/mcp-tasks.mdx diff --git a/docs.json b/docs.json index 98369b6d5..e39859340 100644 --- a/docs.json +++ b/docs.json @@ -748,7 +748,8 @@ "pages": [ "redis/sdks/agentkit/ai-sdk", "redis/sdks/agentkit/eve", - "redis/sdks/agentkit/tanstack-ai" + "redis/sdks/agentkit/tanstack-ai", + "redis/sdks/agentkit/mcp-tasks" ] }, "redis/sdks/mcp", @@ -894,7 +895,8 @@ "pages": [ "redis/sdks/agentkit/ai-sdk", "redis/sdks/agentkit/eve", - "redis/sdks/agentkit/tanstack-ai" + "redis/sdks/agentkit/tanstack-ai", + "redis/sdks/agentkit/mcp-tasks" ] } ] @@ -1687,6 +1689,7 @@ "pages": [ "qstash/sdks/ts/overview", "qstash/sdks/ts/gettingstarted", + "qstash/sdks/ts/mcp-tasks", { "group": "Examples", "pages": [ @@ -2870,6 +2873,10 @@ { "source": "/qstash/api/url-groups/remove", "destination": "/qstash/api-reference/url-groups/delete-a-url-group" + }, + { + "source": "/qstash/sdks/ts/mcp-tasks", + "destination": "/redis/sdks/agentkit/mcp-tasks" } ] } diff --git a/llms-full.txt b/llms-full.txt index 55526efc8..2b5cea256 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -21747,6 +21747,13 @@ const client = new Client({ }); ``` +# Long-Running MCP Tools with QStash +Source: https://upstash.com/docs/qstash/sdks/ts/mcp-tasks + + + Durable long-running MCP tools on QStash or Upstash Workflow, with the task record in Upstash Redis. + + # Overview Source: https://upstash.com/docs/qstash/sdks/ts/overview @@ -87166,6 +87173,540 @@ Two complete `eve` apps live in the AgentKit repo: +# Long-Running MCP Tools with Redis and QStash +Source: https://upstash.com/docs/redis/sdks/agentkit/mcp-tasks + +An MCP tool call is a request that waits for its response. When the work takes minutes, something +gives up first: the client's tool timeout, your serverless function's limit, or the model. + +`@upstash/mcp-tasks` turns a slow tool into a task. The tool answers immediately with a task id, the +work runs on [QStash](/docs/qstash/overall/getstarted) or [Upstash Workflow](/docs/workflow/getstarted) so it +survives restarts and time limits, and the task record lives in Upstash Redis. The model checks on it +with a shared `task_status` tool and can stop it with `task_cancel`. + +These are ordinary MCP tools, so they work in every client today: Claude Code, Codex, Cursor, +OpenCode and ChatGPT. No client capability is required. + +```bash +npm install @upstash/mcp-tasks @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +``` + +## Quickstart + +Three files in a Next.js app. + +### 1. Define the task layer and the server + +```ts lib/tasks.ts +import { McpServer } from "@modelcontextprotocol/server"; +import { createTaskLayer } from "@upstash/mcp-tasks"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; +import * as z from "zod"; + +// Where the task record lives, and how the work is delivered. +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), +}); + +export function createServer() { + const server = new McpServer({ name: "reports", version: "1.0.0" }); + + // Adds three tools to the server: generate_report, task_status and task_cancel. + tasks.registerTask( + server, + "generate_report", + { + description: "Generates a report on a topic.", + inputSchema: z.object({ topic: z.string() }), + }, + // The handler: your long-running work. It runs later, in /api/execute, + // when QStash delivers the task — not inside the tool call. + async ({ topic }, task) => { + await task.update("Researching sources"); + const report = await writeReport(topic); + return { content: [{ type: "text", text: report }] }; + }, + ); + + return server; +} + +// Registers the handler in every process, including an /api/execute +// instance that never serves an MCP request. +createServer(); +``` + +`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. + +### 2. Serve the MCP endpoint + +```ts app/api/mcp/route.ts +import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createServer } from "../../../lib/tasks"; + +const mcp = createMcpHandler(() => createServer()); + +export const POST = (request: Request) => mcp.fetch(request); +``` + +The task layer only registers tools, so this is the SDK's own handler, unchanged. It works the same +with [`mcp-handler`](https://www.npmjs.com/package/mcp-handler) or any transport. + +### 3. Add the execute endpoint + +```ts app/api/execute/route.ts +import { tasks } from "../../../lib/tasks"; + +export const POST = tasks.createExecuteHandler(); +``` + +This is where QStash delivers each task. The handler it returns verifies the QStash signature, finds +the task's handler, runs it and stores the result. It answers `200` when done and `500` to ask QStash +for a retry. + +### Environment variables + +```bash .env +UPSTASH_REDIS_REST_URL=... +UPSTASH_REDIS_REST_TOKEN=... +QSTASH_TOKEN=... +QSTASH_CURRENT_SIGNING_KEY=... +QSTASH_NEXT_SIGNING_KEY=... +# Where QStash delivers tasks. Must be publicly reachable. +APP_URL=https://your-app.vercel.app +``` + + + For local development, run the [QStash dev server](/docs/qstash/howto/local-development) with + `npx @upstash/qstash-cli dev`, set `QSTASH_URL=http://127.0.0.1:8080` and the credentials it prints, + and use `APP_URL=http://127.0.0.1:3000`. + + +## What the model sees + +1. It calls `generate_report({ topic })` and gets back a `taskId`, `status: "working"`, and a line + telling it to call `task_status` in about two seconds. +2. `task_status({ taskId })` returns progress while the task runs, for example + `"Researching sources"`. +3. Once the task completes, `task_status` returns your handler's `content`, exactly as if the tool + had run synchronously. + +`task_cancel({ taskId })` marks the task `cancelled` and stops any pending delivery. Each response +also carries the task object in `structuredContent`: + +```json +{ + "taskId": "0e30…", + "status": "working", + "statusMessage": "Researching sources", + "pollIntervalMs": 2000, + "ttlMs": 300000 +} +``` + +A task is `working`, `completed`, `failed` or `cancelled`. The last three are final. + +## The handler's `task` argument + +| Member | What it does | +| --- | --- | +| `task.taskId` | The id of the running task. | +| `task.update(message)` | Writes a progress line. The model sees it on its next `task_status`. | +| `task.isCancelled()` | `true` once `task_cancel` was called, or the task expired. | + +Cancellation is cooperative: your code keeps running until it checks. Check between steps: + +```ts +async ({ sources }, task) => { + for (const source of sources) { + if (await task.isCancelled()) return {}; + await task.update(`Reading ${source}`); + await read(source); + } + return { content: [{ type: "text", text: "Done" }] }; +}; +``` + + + Arguments are validated when the tool is called, then stored as JSON and handed to the handler + later. Values that don't survive JSON, such as a `Date` produced by `z.coerce.date()`, arrive as + strings. Keep task schemas to plain values, or parse them again inside the handler. + + +## Multi-user servers + +Without `principal`, anyone holding a task id can read and cancel that task. With it, each task +records who started it, and `task_status` / `task_cancel` only answer for that caller. Another user's +task id reads like an unknown one. + +```ts +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + // Receives the AuthInfo your auth middleware attached to the request. + principal: (auth) => auth?.extra?.userId as string | undefined, +}); +``` + +Key on the user, not `auth.clientId`: the client id identifies the OAuth app, which can be one id +shared by every user of a host like ChatGPT. + +## Retries without duplicates + +Agents retry tool calls, especially ones that seemed to time out. With `idempotencyKey`, a second call +from the same caller with the same key returns the existing task instead of starting another: + +```ts +tasks.registerTask( + server, + "generate_report", + { + description: "Generates a report on a topic.", + inputSchema: z.object({ topic: z.string() }), + idempotencyKey: ({ topic }) => topic, + }, + handler, +); +``` + +Keys are scoped by caller and tool, and last as long as the task is kept (`ttlMs`). + +## Work longer than one function invocation + +A QStash delivery is one serverless invocation, so the handler has to finish within your platform's +function limit. If it is killed, QStash redelivers and the handler starts over. + +For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, and finished steps +are replayed from a journal instead of re-run, so a task can run for as long as it needs: + +```ts lib/tasks.ts +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-tasks/upstash"; +import type { WorkflowContext } from "@upstash/workflow"; + +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), +}); +``` + +The handler's `task` then also has the Workflow API, such as `task.run`, `task.sleep` and +`task.call`: + +```ts +async ({ topic }, task) => { + const sources = await task.run("fetch", () => fetchSources(topic)); + const report = await task.run("write", () => writeReport(sources)); + return { content: [{ type: "text", text: report }] }; +}; +``` + +Put side effects inside `task.run`. Code outside a step runs again on every invocation. + +| | `QStashDispatcher` | `WorkflowDispatcher` | +| --- | --- | --- | +| Survives a crash | Yes, redelivered | Yes, replayed | +| Outlives one invocation | No | Yes, one invocation per step | +| On retry | Starts the handler over | Resumes from the last finished step | + +## Options + +**`createTaskLayer`** + +| Option | Default | | +| --- | --- | --- | +| `store` | required | `RedisTaskStore`, or your own `TaskStore` | +| `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | +| `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | +| `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | +| `principal` | none | `(auth) => string \| undefined`. Scopes tasks to their caller. | +| `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | + +**`registerTask` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, +`pollIntervalMs`, `queuedMessage`, `completedMessage` and `idempotencyKey`. + +## Why tools, not the Tasks extension? + +The 2026-07-28 MCP specification defines a [Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) +where the *client* polls `tasks/get`, so the model spends no turns waiting. It is the right long-term +shape, but a server may only return a task to a client that declares the extension, and the clients +people use don't yet. Plain tools work everywhere today. + +The task record already has the extension's shape, so a native adapter can serve the same records +once clients support it. + +## Example + +The [MCP Tasks demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-tasks-demo) is a +Next.js app that runs the same tool on QStash and on Workflow, with a live log of every MCP request. + +# Long-Running MCP Tools with Redis and QStash +Source: https://upstash.com/docs/redis/sdks/agentkit/mcp-tasks + +An MCP tool call is a request that waits for its response. When the work takes minutes, something +gives up first: the client's tool timeout, your serverless function's limit, or the model. + +`@upstash/mcp-tasks` turns a slow tool into a task. The tool answers immediately with a task id, the +work runs on [QStash](/docs/qstash/overall/getstarted) or [Upstash Workflow](/docs/workflow/getstarted) so it +survives restarts and time limits, and the task record lives in Upstash Redis. The model checks on it +with a shared `task_status` tool and can stop it with `task_cancel`. + +These are ordinary MCP tools, so they work in every client today: Claude Code, Codex, Cursor, +OpenCode and ChatGPT. No client capability is required. + +```bash +npm install @upstash/mcp-tasks @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +``` + +## Quickstart + +Three files in a Next.js app. + +### 1. Define the task layer and the server + +```ts lib/tasks.ts +import { McpServer } from "@modelcontextprotocol/server"; +import { createTaskLayer } from "@upstash/mcp-tasks"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; +import * as z from "zod"; + +// Where the task record lives, and how the work is delivered. +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), +}); + +export function createServer() { + const server = new McpServer({ name: "reports", version: "1.0.0" }); + + // Adds three tools to the server: generate_report, task_status and task_cancel. + tasks.registerTask( + server, + "generate_report", + { + description: "Generates a report on a topic.", + inputSchema: z.object({ topic: z.string() }), + }, + // The handler: your long-running work. It runs later, in /api/execute, + // when QStash delivers the task — not inside the tool call. + async ({ topic }, task) => { + await task.update("Researching sources"); + const report = await writeReport(topic); + return { content: [{ type: "text", text: report }] }; + }, + ); + + return server; +} + +// Registers the handler in every process, including an /api/execute +// instance that never serves an MCP request. +createServer(); +``` + +`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. + +### 2. Serve the MCP endpoint + +```ts app/api/mcp/route.ts +import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createServer } from "../../../lib/tasks"; + +const mcp = createMcpHandler(() => createServer()); + +export const POST = (request: Request) => mcp.fetch(request); +``` + +The task layer only registers tools, so this is the SDK's own handler, unchanged. It works the same +with [`mcp-handler`](https://www.npmjs.com/package/mcp-handler) or any transport. + +### 3. Add the execute endpoint + +```ts app/api/execute/route.ts +import { tasks } from "../../../lib/tasks"; + +export const POST = tasks.createExecuteHandler(); +``` + +This is where QStash delivers each task. The handler it returns verifies the QStash signature, finds +the task's handler, runs it and stores the result. It answers `200` when done and `500` to ask QStash +for a retry. + +### Environment variables + +```bash .env +UPSTASH_REDIS_REST_URL=... +UPSTASH_REDIS_REST_TOKEN=... +QSTASH_TOKEN=... +QSTASH_CURRENT_SIGNING_KEY=... +QSTASH_NEXT_SIGNING_KEY=... +# Where QStash delivers tasks. Must be publicly reachable. +APP_URL=https://your-app.vercel.app +``` + + + For local development, run the [QStash dev server](/docs/qstash/howto/local-development) with + `npx @upstash/qstash-cli dev`, set `QSTASH_URL=http://127.0.0.1:8080` and the credentials it prints, + and use `APP_URL=http://127.0.0.1:3000`. + + +## What the model sees + +1. It calls `generate_report({ topic })` and gets back a `taskId`, `status: "working"`, and a line + telling it to call `task_status` in about two seconds. +2. `task_status({ taskId })` returns progress while the task runs, for example + `"Researching sources"`. +3. Once the task completes, `task_status` returns your handler's `content`, exactly as if the tool + had run synchronously. + +`task_cancel({ taskId })` marks the task `cancelled` and stops any pending delivery. Each response +also carries the task object in `structuredContent`: + +```json +{ + "taskId": "0e30…", + "status": "working", + "statusMessage": "Researching sources", + "pollIntervalMs": 2000, + "ttlMs": 300000 +} +``` + +A task is `working`, `completed`, `failed` or `cancelled`. The last three are final. + +## The handler's `task` argument + +| Member | What it does | +| --- | --- | +| `task.taskId` | The id of the running task. | +| `task.update(message)` | Writes a progress line. The model sees it on its next `task_status`. | +| `task.isCancelled()` | `true` once `task_cancel` was called, or the task expired. | + +Cancellation is cooperative: your code keeps running until it checks. Check between steps: + +```ts +async ({ sources }, task) => { + for (const source of sources) { + if (await task.isCancelled()) return {}; + await task.update(`Reading ${source}`); + await read(source); + } + return { content: [{ type: "text", text: "Done" }] }; +}; +``` + + + Arguments are validated when the tool is called, then stored as JSON and handed to the handler + later. Values that don't survive JSON, such as a `Date` produced by `z.coerce.date()`, arrive as + strings. Keep task schemas to plain values, or parse them again inside the handler. + + +## Multi-user servers + +Without `principal`, anyone holding a task id can read and cancel that task. With it, each task +records who started it, and `task_status` / `task_cancel` only answer for that caller. Another user's +task id reads like an unknown one. + +```ts +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + // Receives the AuthInfo your auth middleware attached to the request. + principal: (auth) => auth?.extra?.userId as string | undefined, +}); +``` + +Key on the user, not `auth.clientId`: the client id identifies the OAuth app, which can be one id +shared by every user of a host like ChatGPT. + +## Retries without duplicates + +Agents retry tool calls, especially ones that seemed to time out. With `idempotencyKey`, a second call +from the same caller with the same key returns the existing task instead of starting another: + +```ts +tasks.registerTask( + server, + "generate_report", + { + description: "Generates a report on a topic.", + inputSchema: z.object({ topic: z.string() }), + idempotencyKey: ({ topic }) => topic, + }, + handler, +); +``` + +Keys are scoped by caller and tool, and last as long as the task is kept (`ttlMs`). + +## Work longer than one function invocation + +A QStash delivery is one serverless invocation, so the handler has to finish within your platform's +function limit. If it is killed, QStash redelivers and the handler starts over. + +For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, and finished steps +are replayed from a journal instead of re-run, so a task can run for as long as it needs: + +```ts lib/tasks.ts +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-tasks/upstash"; +import type { WorkflowContext } from "@upstash/workflow"; + +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), +}); +``` + +The handler's `task` then also has the Workflow API, such as `task.run`, `task.sleep` and +`task.call`: + +```ts +async ({ topic }, task) => { + const sources = await task.run("fetch", () => fetchSources(topic)); + const report = await task.run("write", () => writeReport(sources)); + return { content: [{ type: "text", text: report }] }; +}; +``` + +Put side effects inside `task.run`. Code outside a step runs again on every invocation. + +| | `QStashDispatcher` | `WorkflowDispatcher` | +| --- | --- | --- | +| Survives a crash | Yes, redelivered | Yes, replayed | +| Outlives one invocation | No | Yes, one invocation per step | +| On retry | Starts the handler over | Resumes from the last finished step | + +## Options + +**`createTaskLayer`** + +| Option | Default | | +| --- | --- | --- | +| `store` | required | `RedisTaskStore`, or your own `TaskStore` | +| `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | +| `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | +| `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | +| `principal` | none | `(auth) => string \| undefined`. Scopes tasks to their caller. | +| `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | + +**`registerTask` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, +`pollIntervalMs`, `queuedMessage`, `completedMessage` and `idempotencyKey`. + +## Why tools, not the Tasks extension? + +The 2026-07-28 MCP specification defines a [Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) +where the *client* polls `tasks/get`, so the model spends no turns waiting. It is the right long-term +shape, but a server may only return a task to a client that declares the extension, and the clients +people use don't yet. Plain tools work everywhere today. + +The task record already has the extension's shape, so a native adapter can serve the same records +once clients support it. + +## Example + +The [MCP Tasks demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-tasks-demo) is a +Next.js app that runs the same tool on QStash and on Workflow, with a live log of every MCP request. + # TanStack AI Persistence, Resumable Streams & Memory with Redis Source: https://upstash.com/docs/redis/sdks/agentkit/tanstack-ai diff --git a/llms.txt b/llms.txt index 445da8970..7b96eb8ba 100644 --- a/llms.txt +++ b/llms.txt @@ -314,6 +314,7 @@ Search these docs: GET https://upstash.com/docs/search?q= returns JSON { - [Schedules](https://upstash.com/docs/qstash/sdks/ts/examples/schedules.md) - [URL Groups](https://upstash.com/docs/qstash/sdks/ts/examples/url-groups.md) - [Getting Started](https://upstash.com/docs/qstash/sdks/ts/gettingstarted.md) +- [Long-Running MCP Tools with QStash](https://upstash.com/docs/qstash/sdks/ts/mcp-tasks.md): Run long MCP tool calls durably on QStash with @upstash/mcp-tasks. - [Overview](https://upstash.com/docs/qstash/sdks/ts/overview.md) - [Channels](https://upstash.com/docs/realtime/features/channels.md) - [Client-Side Usage](https://upstash.com/docs/realtime/features/client-side.md) @@ -741,6 +742,8 @@ Search these docs: GET https://upstash.com/docs/search?q= returns JSON { - [Vercel AI SDK Memory, RAG & Chat History with Redis](https://upstash.com/docs/redis/sdks/agentkit/ai-sdk.md): Add long-term memory, RAG, and chat history to the Vercel AI SDK with Upstash Redis — drop-in tools for generateText and streamText, no separate vector database. - [Memory, Chat History, RAG, Rate Limiting & Sandboxes for the Vercel Eve Agent Framework](https://upstash.com/docs/redis/sdks/agentkit/eve.md): Add long-term memory, searchable chat history, RAG, rate limiting, tool caching, and sandboxes to Vercel's Eve agent framework with Upstash Redis — no separate vector database. - [Memory, Chat History, RAG, Rate Limiting & Sandboxes for the Vercel Eve Agent Framework](https://upstash.com/docs/redis/sdks/agentkit/eve.md): Add long-term memory, searchable chat history, RAG, rate limiting, tool caching, and sandboxes to Vercel's Eve agent framework with Upstash Redis — no separate vector database. +- [Long-Running MCP Tools with Redis and QStash](https://upstash.com/docs/redis/sdks/agentkit/mcp-tasks.md): Durable long-running tools for MCP servers: a tool answers with a task id at once, the work runs on QStash or Upstash Workflow, and the model polls for the result. Works in every MCP client. +- [Long-Running MCP Tools with Redis and QStash](https://upstash.com/docs/redis/sdks/agentkit/mcp-tasks.md): Durable long-running tools for MCP servers: a tool answers with a task id at once, the work runs on QStash or Upstash Workflow, and the model polls for the result. Works in every MCP client. - [TanStack AI Persistence, Resumable Streams & Memory with Redis](https://upstash.com/docs/redis/sdks/agentkit/tanstack-ai.md): Production backends for TanStack AI on Upstash Redis — chat persistence, resumable streams, distributed locks, long-term memory, tool caching, rate limiting, and RAG tools. - [TanStack AI Persistence, Resumable Streams & Memory with Redis](https://upstash.com/docs/redis/sdks/agentkit/tanstack-ai.md): Production backends for TanStack AI on Upstash Redis — chat persistence, resumable streams, distributed locks, long-term memory, tool caching, rate limiting, and RAG tools. - [Upstash Redis MCP](https://upstash.com/docs/redis/sdks/mcp.md) diff --git a/qstash/sdks/ts/mcp-tasks.mdx b/qstash/sdks/ts/mcp-tasks.mdx new file mode 100644 index 000000000..e46cbf377 --- /dev/null +++ b/qstash/sdks/ts/mcp-tasks.mdx @@ -0,0 +1,9 @@ +--- +title: "Long-Running MCP Tools with QStash" +sidebarTitle: "MCP Tasks" +description: "Run long MCP tool calls durably on QStash with @upstash/mcp-tasks." +--- + + + Durable long-running MCP tools on QStash or Upstash Workflow, with the task record in Upstash Redis. + diff --git a/redis/sdks/agentkit/mcp-tasks.mdx b/redis/sdks/agentkit/mcp-tasks.mdx new file mode 100644 index 000000000..a30b7cbcb --- /dev/null +++ b/redis/sdks/agentkit/mcp-tasks.mdx @@ -0,0 +1,269 @@ +--- +title: "Long-Running MCP Tools with Redis and QStash" +sidebarTitle: "MCP Tasks" +description: "Durable long-running tools for MCP servers: a tool answers with a task id at once, the work runs on QStash or Upstash Workflow, and the model polls for the result. Works in every MCP client." +--- + +An MCP tool call is a request that waits for its response. When the work takes minutes, something +gives up first: the client's tool timeout, your serverless function's limit, or the model. + +`@upstash/mcp-tasks` turns a slow tool into a task. The tool answers immediately with a task id, the +work runs on [QStash](/qstash/overall/getstarted) or [Upstash Workflow](/workflow/getstarted) so it +survives restarts and time limits, and the task record lives in Upstash Redis. The model checks on it +with a shared `task_status` tool and can stop it with `task_cancel`. + +These are ordinary MCP tools, so they work in every client today: Claude Code, Codex, Cursor, +OpenCode and ChatGPT. No client capability is required. + +```bash +npm install @upstash/mcp-tasks @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +``` + +## Quickstart + +Three files in a Next.js app. + +### 1. Define the task layer and the server + +```ts lib/tasks.ts +import { McpServer } from "@modelcontextprotocol/server"; +import { createTaskLayer } from "@upstash/mcp-tasks"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; +import * as z from "zod"; + +// Where the task record lives, and how the work is delivered. +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), +}); + +export function createServer() { + const server = new McpServer({ name: "reports", version: "1.0.0" }); + + // Adds three tools to the server: generate_report, task_status and task_cancel. + tasks.registerTask( + server, + "generate_report", + { + description: "Generates a report on a topic.", + inputSchema: z.object({ topic: z.string() }), + }, + // The handler: your long-running work. It runs later, in /api/execute, + // when QStash delivers the task — not inside the tool call. + async ({ topic }, task) => { + await task.update("Researching sources"); + const report = await writeReport(topic); + return { content: [{ type: "text", text: report }] }; + }, + ); + + return server; +} + +// Registers the handler in every process, including an /api/execute +// instance that never serves an MCP request. +createServer(); +``` + +`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. + +### 2. Serve the MCP endpoint + +```ts app/api/mcp/route.ts +import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createServer } from "../../../lib/tasks"; + +const mcp = createMcpHandler(() => createServer()); + +export const POST = (request: Request) => mcp.fetch(request); +``` + +The task layer only registers tools, so this is the SDK's own handler, unchanged. It works the same +with [`mcp-handler`](https://www.npmjs.com/package/mcp-handler) or any transport. + +### 3. Add the execute endpoint + +```ts app/api/execute/route.ts +import { tasks } from "../../../lib/tasks"; + +export const POST = tasks.createExecuteHandler(); +``` + +This is where QStash delivers each task. The handler it returns verifies the QStash signature, finds +the task's handler, runs it and stores the result. It answers `200` when done and `500` to ask QStash +for a retry. + +### Environment variables + +```bash .env +UPSTASH_REDIS_REST_URL=... +UPSTASH_REDIS_REST_TOKEN=... +QSTASH_TOKEN=... +QSTASH_CURRENT_SIGNING_KEY=... +QSTASH_NEXT_SIGNING_KEY=... +# Where QStash delivers tasks. Must be publicly reachable. +APP_URL=https://your-app.vercel.app +``` + + + For local development, run the [QStash dev server](/qstash/howto/local-development) with + `npx @upstash/qstash-cli dev`, set `QSTASH_URL=http://127.0.0.1:8080` and the credentials it prints, + and use `APP_URL=http://127.0.0.1:3000`. + + +## What the model sees + +1. It calls `generate_report({ topic })` and gets back a `taskId`, `status: "working"`, and a line + telling it to call `task_status` in about two seconds. +2. `task_status({ taskId })` returns progress while the task runs, for example + `"Researching sources"`. +3. Once the task completes, `task_status` returns your handler's `content`, exactly as if the tool + had run synchronously. + +`task_cancel({ taskId })` marks the task `cancelled` and stops any pending delivery. Each response +also carries the task object in `structuredContent`: + +```json +{ + "taskId": "0e30…", + "status": "working", + "statusMessage": "Researching sources", + "pollIntervalMs": 2000, + "ttlMs": 300000 +} +``` + +A task is `working`, `completed`, `failed` or `cancelled`. The last three are final. + +## The handler's `task` argument + +| Member | What it does | +| --- | --- | +| `task.taskId` | The id of the running task. | +| `task.update(message)` | Writes a progress line. The model sees it on its next `task_status`. | +| `task.isCancelled()` | `true` once `task_cancel` was called, or the task expired. | + +Cancellation is cooperative: your code keeps running until it checks. Check between steps: + +```ts +async ({ sources }, task) => { + for (const source of sources) { + if (await task.isCancelled()) return {}; + await task.update(`Reading ${source}`); + await read(source); + } + return { content: [{ type: "text", text: "Done" }] }; +}; +``` + + + Arguments are validated when the tool is called, then stored as JSON and handed to the handler + later. Values that don't survive JSON, such as a `Date` produced by `z.coerce.date()`, arrive as + strings. Keep task schemas to plain values, or parse them again inside the handler. + + +## Multi-user servers + +Without `principal`, anyone holding a task id can read and cancel that task. With it, each task +records who started it, and `task_status` / `task_cancel` only answer for that caller. Another user's +task id reads like an unknown one. + +```ts +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + // Receives the AuthInfo your auth middleware attached to the request. + principal: (auth) => auth?.extra?.userId as string | undefined, +}); +``` + +Key on the user, not `auth.clientId`: the client id identifies the OAuth app, which can be one id +shared by every user of a host like ChatGPT. + +## Retries without duplicates + +Agents retry tool calls, especially ones that seemed to time out. With `idempotencyKey`, a second call +from the same caller with the same key returns the existing task instead of starting another: + +```ts +tasks.registerTask( + server, + "generate_report", + { + description: "Generates a report on a topic.", + inputSchema: z.object({ topic: z.string() }), + idempotencyKey: ({ topic }) => topic, + }, + handler, +); +``` + +Keys are scoped by caller and tool, and last as long as the task is kept (`ttlMs`). + +## Work longer than one function invocation + +A QStash delivery is one serverless invocation, so the handler has to finish within your platform's +function limit. If it is killed, QStash redelivers and the handler starts over. + +For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, and finished steps +are replayed from a journal instead of re-run, so a task can run for as long as it needs: + +```ts lib/tasks.ts +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-tasks/upstash"; +import type { WorkflowContext } from "@upstash/workflow"; + +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), +}); +``` + +The handler's `task` then also has the Workflow API, such as `task.run`, `task.sleep` and +`task.call`: + +```ts +async ({ topic }, task) => { + const sources = await task.run("fetch", () => fetchSources(topic)); + const report = await task.run("write", () => writeReport(sources)); + return { content: [{ type: "text", text: report }] }; +}; +``` + +Put side effects inside `task.run`. Code outside a step runs again on every invocation. + +| | `QStashDispatcher` | `WorkflowDispatcher` | +| --- | --- | --- | +| Survives a crash | Yes, redelivered | Yes, replayed | +| Outlives one invocation | No | Yes, one invocation per step | +| On retry | Starts the handler over | Resumes from the last finished step | + +## Options + +**`createTaskLayer`** + +| Option | Default | | +| --- | --- | --- | +| `store` | required | `RedisTaskStore`, or your own `TaskStore` | +| `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | +| `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | +| `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | +| `principal` | none | `(auth) => string \| undefined`. Scopes tasks to their caller. | +| `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | + +**`registerTask` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, +`pollIntervalMs`, `queuedMessage`, `completedMessage` and `idempotencyKey`. + +## Why tools, not the Tasks extension? + +The 2026-07-28 MCP specification defines a [Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) +where the *client* polls `tasks/get`, so the model spends no turns waiting. It is the right long-term +shape, but a server may only return a task to a client that declares the extension, and the clients +people use don't yet. Plain tools work everywhere today. + +The task record already has the extension's shape, so a native adapter can serve the same records +once clients support it. + +## Example + +The [MCP Tasks demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-tasks-demo) is a +Next.js app that runs the same tool on QStash and on Workflow, with a live log of every MCP request. From ad0bef5c44a42051c848ce31ca29f29c0d9b0435 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Wed, 7 Oct 2026 13:36:35 +0000 Subject: [PATCH 02/16] DX-3022: docs: MCP Events page, @upstash/mcp-toolkit imports The package is now @upstash/mcp-toolkit. The MCP Tasks page uses the /tasks imports and gains onSettle plus a "push instead of poll" section. New page redis/sdks/agentkit/mcp-events for /events, in both AgentKit groups, with a QStash TS SDK stub that redirects to it. llms regenerated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd --- docs.json | 11 +- llms-full.txt | 497 ++++++++++++++++++++++++++++- llms.txt | 5 +- qstash/sdks/ts/mcp-events.mdx | 9 + qstash/sdks/ts/mcp-tasks.mdx | 2 +- redis/sdks/agentkit/mcp-events.mdx | 220 +++++++++++++ redis/sdks/agentkit/mcp-tasks.mdx | 27 +- 7 files changed, 749 insertions(+), 22 deletions(-) create mode 100644 qstash/sdks/ts/mcp-events.mdx create mode 100644 redis/sdks/agentkit/mcp-events.mdx diff --git a/docs.json b/docs.json index e39859340..134daf489 100644 --- a/docs.json +++ b/docs.json @@ -749,7 +749,8 @@ "redis/sdks/agentkit/ai-sdk", "redis/sdks/agentkit/eve", "redis/sdks/agentkit/tanstack-ai", - "redis/sdks/agentkit/mcp-tasks" + "redis/sdks/agentkit/mcp-tasks", + "redis/sdks/agentkit/mcp-events" ] }, "redis/sdks/mcp", @@ -896,7 +897,8 @@ "redis/sdks/agentkit/ai-sdk", "redis/sdks/agentkit/eve", "redis/sdks/agentkit/tanstack-ai", - "redis/sdks/agentkit/mcp-tasks" + "redis/sdks/agentkit/mcp-tasks", + "redis/sdks/agentkit/mcp-events" ] } ] @@ -1690,6 +1692,7 @@ "qstash/sdks/ts/overview", "qstash/sdks/ts/gettingstarted", "qstash/sdks/ts/mcp-tasks", + "qstash/sdks/ts/mcp-events", { "group": "Examples", "pages": [ @@ -2877,6 +2880,10 @@ { "source": "/qstash/sdks/ts/mcp-tasks", "destination": "/redis/sdks/agentkit/mcp-tasks" + }, + { + "source": "/qstash/sdks/ts/mcp-events", + "destination": "/redis/sdks/agentkit/mcp-events" } ] } diff --git a/llms-full.txt b/llms-full.txt index 2b5cea256..c034169c0 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -21747,6 +21747,13 @@ const client = new Client({ }); ``` +# MCP Events with QStash +Source: https://upstash.com/docs/qstash/sdks/ts/mcp-events + + + MCP Events for your server: subscriptions in Upstash Redis, signed webhook deliveries retried by QStash. + + # Long-Running MCP Tools with QStash Source: https://upstash.com/docs/qstash/sdks/ts/mcp-tasks @@ -87173,13 +87180,449 @@ Two complete `eve` apps live in the AgentKit repo: +# MCP Events with Redis and QStash +Source: https://upstash.com/docs/redis/sdks/agentkit/mcp-events + +Most agents only act when someone messages them. [MCP Events](https://github.com/modelcontextprotocol/experimental-ext-triggers-events) +lets an agent wait for something instead: the host subscribes to an event on your MCP server, and +when it happens your server POSTs a signed webhook that wakes the agent up. + +`@upstash/mcp-toolkit/events` implements the server side. You define events with typed payloads and +call `emit` when something happens. The toolkit handles subscriptions, callback verification, +signing and retries: subscriptions live in Upstash Redis, and every delivery goes through +[QStash](/docs/qstash/overall/getstarted), which retries failed webhooks with backoff. + + + MCP Events is a draft. As of October 2026, ChatGPT is the host that subscribes to it (webhook + delivery, in Work chats), following [OpenAI's MCP Events guide](https://developers.openai.com/plugins/build/mcp-events). + Claude Code, Cursor and OpenCode don't subscribe yet. + + +```bash +npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +``` + +## Quickstart + +Three files in a Next.js app. + +### 1. Define the event layer and your events + +```ts lib/events.ts +import { createEventLayer } from "@upstash/mcp-toolkit/events"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events/upstash"; +import * as z from "zod"; + +export const events = createEventLayer({ + store: new RedisSubscriptionStore(), + delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), + // Receives the AuthInfo your auth middleware attached to the request. + principal: (auth) => auth?.extra?.userId as string | undefined, +}); + +export const commentCreated = events.define("comment.created", { + description: "A new review comment was added to a document.", + // What a subscriber filters on. + input: z.object({ documentId: z.string() }), + // What every delivery carries. + payload: z.object({ documentId: z.string(), commentId: z.string(), text: z.string() }), + // Optional: who may subscribe with these arguments. + authorize: (args, { principal }) => canRead(principal, args.documentId), +}); +``` + +### 2. Register the events on your MCP server + +```ts lib/server.ts +import { McpServer } from "@modelcontextprotocol/server"; +import { events } from "./events"; + +export function createServer() { + const server = new McpServer({ name: "docs", version: "1.0.0" }); + // Adds events/list, events/subscribe and events/unsubscribe next to your tools. + events.register(server); + return server; +} +``` + +Serve it with `createMcpHandler(() => createServer())` as usual. Nothing else changes on the MCP +endpoint. + +### 3. Add the delivery endpoint + +```ts app/api/events/route.ts +import { events } from "../../../lib/events"; + +export const POST = events.createDeliveryHandler(); +``` + +QStash delivers each event here. The handler verifies the QStash signature, signs the envelope +with the subscriber's secret, POSTs it to the host, and answers `500` when the host's callback +failed so QStash retries. + +### Emit + +Call `emit` wherever the change happens: a route, a webhook from your own app, a background job. + +```ts +await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); +``` + +`emit` is typed by the `payload` schema and validates against it. It returns the event id and how +many subscriptions matched. + +### Environment variables + +```bash .env +UPSTASH_REDIS_REST_URL=... +UPSTASH_REDIS_REST_TOKEN=... +QSTASH_TOKEN=... +QSTASH_CURRENT_SIGNING_KEY=... +QSTASH_NEXT_SIGNING_KEY=... +# Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 +MCP_EVENTS_SECRET_KEY=... +# Where QStash delivers events. Must be publicly reachable. +APP_URL=https://your-app.vercel.app +``` + +## What happens on the wire + +1. The host calls `events/subscribe` with the event name, arguments, its callback URL and a + `whsec_` signing secret. +2. Your server POSTs a signed verification challenge to the callback and requires it echoed back, + then stores the subscription and answers with its `id` and a `refreshBefore` time. +3. When you `emit`, every matching subscription gets a POST with the event envelope and + [Standard Webhooks](https://www.standardwebhooks.com/) headers: + +```json +{ + "eventId": "evt_7c1…", + "name": "comment.created", + "timestamp": "2026-10-07T12:05:00Z", + "data": { "documentId": "doc_123", "commentId": "c_9", "text": "Ship it?" }, + "cursor": null +} +``` + +The event id stays the same across retries, so the host can drop duplicates. The signature is +computed fresh on every attempt. + +## Matching + +A subscription matches when every argument it gave equals the emitted value. Emitting +`{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of +`{ repo: "a", branch: "main" }`, and of `{}`. + +By default the values come from the payload fields named in the input schema. Pass options to +control it: + +```ts +await commentCreated.emit(payload, { + args: { documentId }, // match on these values instead + owner: userId, // only this user's subscriptions + eventId: comment.id, // emitting the same id twice is deduplicated +}); +``` + +For conditions that exact matching can't express, add `match: (args, payload) => boolean` to the +event definition. + +## Task finished events + +If you use [MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks), a `task.finished` event lets an +event-capable host stop polling `task_status`: + +```ts +import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; + +const taskFinished = taskFinishedEvent(events); + +export const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); +``` + +It fires once when a task completes, fails or is cancelled, and only to subscriptions created by +the task's owner. A host subscribes with no arguments for every task, or with `taskId` for one. The +payload includes the result when it fits in the 256 KiB envelope. + +## Security + +* **Callback URLs** must be `https` on a public host. `localhost`, `.internal` names, private and + link-local IPs, and credentials in the URL are refused, and redirects are never followed. +* **Verification:** a subscription is stored only after the callback echoes a signed challenge. + Failures return error `-32015` with a `reason` such as `challenge_failed` or `timeout`. +* **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret + skips the challenge; a new secret verifies again. +* **Authorization:** `authorize` runs on every subscribe and refresh. + + + `allowInsecureCallbacks: true` accepts `http://` and private addresses. Use it only for local + development, never in production. + + +## Host responses + +| Callback answers | What happens | +| --- | --- | +| `2xx` | Delivered. | +| `410 Gone` | The subscription is deleted. | +| `413` | The event is dropped, not retried. | +| Anything else, or no answer | QStash retries with backoff. | + +## Options + +**`createEventLayer`** + +| Option | Default | | +| --- | --- | --- | +| `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | +| `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | +| `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required. | +| `principal` | none | `(auth) => string \| undefined`. Part of the subscription id, passed to `authorize`. | +| `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | +| `defaults.maxTtlMs` | 30 days | The most the server grants | +| `allowInsecureCallbacks` | `false` | Local development only | +| `timeoutMs` | 10 seconds | Timeout for each POST to a callback | + +**`define` config:** `description` and `payload`, plus optional `title`, `input`, `authorize` and +`match`. + +**`QStashDelivery`:** `url` (required), `retries` (default 3), `retryDelay`, `headers`, `qstash`, +`receiver`. + +**`RedisSubscriptionStore`:** `redis`, `prefix` (default `mcp-events:`). + +Poll and stream delivery modes and replay cursors from the draft aren't implemented. + +## Example + +The [MCP toolkit demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-toolkit-demo) +emits `task.finished` and includes a local receiver that verifies each signed delivery. + +# MCP Events with Redis and QStash +Source: https://upstash.com/docs/redis/sdks/agentkit/mcp-events + +Most agents only act when someone messages them. [MCP Events](https://github.com/modelcontextprotocol/experimental-ext-triggers-events) +lets an agent wait for something instead: the host subscribes to an event on your MCP server, and +when it happens your server POSTs a signed webhook that wakes the agent up. + +`@upstash/mcp-toolkit/events` implements the server side. You define events with typed payloads and +call `emit` when something happens. The toolkit handles subscriptions, callback verification, +signing and retries: subscriptions live in Upstash Redis, and every delivery goes through +[QStash](/docs/qstash/overall/getstarted), which retries failed webhooks with backoff. + + + MCP Events is a draft. As of October 2026, ChatGPT is the host that subscribes to it (webhook + delivery, in Work chats), following [OpenAI's MCP Events guide](https://developers.openai.com/plugins/build/mcp-events). + Claude Code, Cursor and OpenCode don't subscribe yet. + + +```bash +npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +``` + +## Quickstart + +Three files in a Next.js app. + +### 1. Define the event layer and your events + +```ts lib/events.ts +import { createEventLayer } from "@upstash/mcp-toolkit/events"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events/upstash"; +import * as z from "zod"; + +export const events = createEventLayer({ + store: new RedisSubscriptionStore(), + delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), + // Receives the AuthInfo your auth middleware attached to the request. + principal: (auth) => auth?.extra?.userId as string | undefined, +}); + +export const commentCreated = events.define("comment.created", { + description: "A new review comment was added to a document.", + // What a subscriber filters on. + input: z.object({ documentId: z.string() }), + // What every delivery carries. + payload: z.object({ documentId: z.string(), commentId: z.string(), text: z.string() }), + // Optional: who may subscribe with these arguments. + authorize: (args, { principal }) => canRead(principal, args.documentId), +}); +``` + +### 2. Register the events on your MCP server + +```ts lib/server.ts +import { McpServer } from "@modelcontextprotocol/server"; +import { events } from "./events"; + +export function createServer() { + const server = new McpServer({ name: "docs", version: "1.0.0" }); + // Adds events/list, events/subscribe and events/unsubscribe next to your tools. + events.register(server); + return server; +} +``` + +Serve it with `createMcpHandler(() => createServer())` as usual. Nothing else changes on the MCP +endpoint. + +### 3. Add the delivery endpoint + +```ts app/api/events/route.ts +import { events } from "../../../lib/events"; + +export const POST = events.createDeliveryHandler(); +``` + +QStash delivers each event here. The handler verifies the QStash signature, signs the envelope +with the subscriber's secret, POSTs it to the host, and answers `500` when the host's callback +failed so QStash retries. + +### Emit + +Call `emit` wherever the change happens: a route, a webhook from your own app, a background job. + +```ts +await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); +``` + +`emit` is typed by the `payload` schema and validates against it. It returns the event id and how +many subscriptions matched. + +### Environment variables + +```bash .env +UPSTASH_REDIS_REST_URL=... +UPSTASH_REDIS_REST_TOKEN=... +QSTASH_TOKEN=... +QSTASH_CURRENT_SIGNING_KEY=... +QSTASH_NEXT_SIGNING_KEY=... +# Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 +MCP_EVENTS_SECRET_KEY=... +# Where QStash delivers events. Must be publicly reachable. +APP_URL=https://your-app.vercel.app +``` + +## What happens on the wire + +1. The host calls `events/subscribe` with the event name, arguments, its callback URL and a + `whsec_` signing secret. +2. Your server POSTs a signed verification challenge to the callback and requires it echoed back, + then stores the subscription and answers with its `id` and a `refreshBefore` time. +3. When you `emit`, every matching subscription gets a POST with the event envelope and + [Standard Webhooks](https://www.standardwebhooks.com/) headers: + +```json +{ + "eventId": "evt_7c1…", + "name": "comment.created", + "timestamp": "2026-10-07T12:05:00Z", + "data": { "documentId": "doc_123", "commentId": "c_9", "text": "Ship it?" }, + "cursor": null +} +``` + +The event id stays the same across retries, so the host can drop duplicates. The signature is +computed fresh on every attempt. + +## Matching + +A subscription matches when every argument it gave equals the emitted value. Emitting +`{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of +`{ repo: "a", branch: "main" }`, and of `{}`. + +By default the values come from the payload fields named in the input schema. Pass options to +control it: + +```ts +await commentCreated.emit(payload, { + args: { documentId }, // match on these values instead + owner: userId, // only this user's subscriptions + eventId: comment.id, // emitting the same id twice is deduplicated +}); +``` + +For conditions that exact matching can't express, add `match: (args, payload) => boolean` to the +event definition. + +## Task finished events + +If you use [MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks), a `task.finished` event lets an +event-capable host stop polling `task_status`: + +```ts +import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; + +const taskFinished = taskFinishedEvent(events); + +export const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); +``` + +It fires once when a task completes, fails or is cancelled, and only to subscriptions created by +the task's owner. A host subscribes with no arguments for every task, or with `taskId` for one. The +payload includes the result when it fits in the 256 KiB envelope. + +## Security + +* **Callback URLs** must be `https` on a public host. `localhost`, `.internal` names, private and + link-local IPs, and credentials in the URL are refused, and redirects are never followed. +* **Verification:** a subscription is stored only after the callback echoes a signed challenge. + Failures return error `-32015` with a `reason` such as `challenge_failed` or `timeout`. +* **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret + skips the challenge; a new secret verifies again. +* **Authorization:** `authorize` runs on every subscribe and refresh. + + + `allowInsecureCallbacks: true` accepts `http://` and private addresses. Use it only for local + development, never in production. + + +## Host responses + +| Callback answers | What happens | +| --- | --- | +| `2xx` | Delivered. | +| `410 Gone` | The subscription is deleted. | +| `413` | The event is dropped, not retried. | +| Anything else, or no answer | QStash retries with backoff. | + +## Options + +**`createEventLayer`** + +| Option | Default | | +| --- | --- | --- | +| `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | +| `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | +| `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required. | +| `principal` | none | `(auth) => string \| undefined`. Part of the subscription id, passed to `authorize`. | +| `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | +| `defaults.maxTtlMs` | 30 days | The most the server grants | +| `allowInsecureCallbacks` | `false` | Local development only | +| `timeoutMs` | 10 seconds | Timeout for each POST to a callback | + +**`define` config:** `description` and `payload`, plus optional `title`, `input`, `authorize` and +`match`. + +**`QStashDelivery`:** `url` (required), `retries` (default 3), `retryDelay`, `headers`, `qstash`, +`receiver`. + +**`RedisSubscriptionStore`:** `redis`, `prefix` (default `mcp-events:`). + +Poll and stream delivery modes and replay cursors from the draft aren't implemented. + +## Example + +The [MCP toolkit demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-toolkit-demo) +emits `task.finished` and includes a local receiver that verifies each signed delivery. + # Long-Running MCP Tools with Redis and QStash Source: https://upstash.com/docs/redis/sdks/agentkit/mcp-tasks An MCP tool call is a request that waits for its response. When the work takes minutes, something gives up first: the client's tool timeout, your serverless function's limit, or the model. -`@upstash/mcp-tasks` turns a slow tool into a task. The tool answers immediately with a task id, the +`@upstash/mcp-toolkit/tasks` turns a slow tool into a task. The tool answers immediately with a task id, the work runs on [QStash](/docs/qstash/overall/getstarted) or [Upstash Workflow](/docs/workflow/getstarted) so it survives restarts and time limits, and the task record lives in Upstash Redis. The model checks on it with a shared `task_status` tool and can stop it with `task_cancel`. @@ -87188,7 +87631,7 @@ These are ordinary MCP tools, so they work in every client today: Claude Code, C OpenCode and ChatGPT. No client capability is required. ```bash -npm install @upstash/mcp-tasks @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod ``` ## Quickstart @@ -87199,8 +87642,8 @@ Three files in a Next.js app. ```ts lib/tasks.ts import { McpServer } from "@modelcontextprotocol/server"; -import { createTaskLayer } from "@upstash/mcp-tasks"; -import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; +import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; import * as z from "zod"; // Where the task record lives, and how the work is delivered. @@ -87381,7 +87824,7 @@ For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, are replayed from a journal instead of re-run, so a task can run for as long as it needs: ```ts lib/tasks.ts -import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-tasks/upstash"; +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; import type { WorkflowContext } from "@upstash/workflow"; export const tasks = createTaskLayer({ @@ -87421,10 +87864,25 @@ Put side effects inside `task.run`. Code outside a step runs again on every invo | `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | | `principal` | none | `(auth) => string \| undefined`. Scopes tasks to their caller. | | `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | +| `onSettle` | none | `(task) => void`. Called once when a task completes, fails or is cancelled. | **`registerTask` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, `pollIntervalMs`, `queuedMessage`, `completedMessage` and `idempotencyKey`. +## Push instead of poll + +Hosts that support MCP Events, ChatGPT today, can get a webhook when a task finishes instead of +polling. Wire the `task.finished` event from [MCP Events](/docs/redis/sdks/agentkit/mcp-events) into +`onSettle`: + +```ts +import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; + +const taskFinished = taskFinishedEvent(events); + +export const tasks = createTaskLayer({ store, dispatcher, onSettle: taskFinished.onSettle }); +``` + ## Why tools, not the Tasks extension? The 2026-07-28 MCP specification defines a [Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) @@ -87437,7 +87895,7 @@ once clients support it. ## Example -The [MCP Tasks demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-tasks-demo) is a +The [MCP toolkit demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-toolkit-demo) is a Next.js app that runs the same tool on QStash and on Workflow, with a live log of every MCP request. # Long-Running MCP Tools with Redis and QStash @@ -87446,7 +87904,7 @@ Source: https://upstash.com/docs/redis/sdks/agentkit/mcp-tasks An MCP tool call is a request that waits for its response. When the work takes minutes, something gives up first: the client's tool timeout, your serverless function's limit, or the model. -`@upstash/mcp-tasks` turns a slow tool into a task. The tool answers immediately with a task id, the +`@upstash/mcp-toolkit/tasks` turns a slow tool into a task. The tool answers immediately with a task id, the work runs on [QStash](/docs/qstash/overall/getstarted) or [Upstash Workflow](/docs/workflow/getstarted) so it survives restarts and time limits, and the task record lives in Upstash Redis. The model checks on it with a shared `task_status` tool and can stop it with `task_cancel`. @@ -87455,7 +87913,7 @@ These are ordinary MCP tools, so they work in every client today: Claude Code, C OpenCode and ChatGPT. No client capability is required. ```bash -npm install @upstash/mcp-tasks @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod ``` ## Quickstart @@ -87466,8 +87924,8 @@ Three files in a Next.js app. ```ts lib/tasks.ts import { McpServer } from "@modelcontextprotocol/server"; -import { createTaskLayer } from "@upstash/mcp-tasks"; -import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; +import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; import * as z from "zod"; // Where the task record lives, and how the work is delivered. @@ -87648,7 +88106,7 @@ For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, are replayed from a journal instead of re-run, so a task can run for as long as it needs: ```ts lib/tasks.ts -import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-tasks/upstash"; +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; import type { WorkflowContext } from "@upstash/workflow"; export const tasks = createTaskLayer({ @@ -87688,10 +88146,25 @@ Put side effects inside `task.run`. Code outside a step runs again on every invo | `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | | `principal` | none | `(auth) => string \| undefined`. Scopes tasks to their caller. | | `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | +| `onSettle` | none | `(task) => void`. Called once when a task completes, fails or is cancelled. | **`registerTask` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, `pollIntervalMs`, `queuedMessage`, `completedMessage` and `idempotencyKey`. +## Push instead of poll + +Hosts that support MCP Events, ChatGPT today, can get a webhook when a task finishes instead of +polling. Wire the `task.finished` event from [MCP Events](/docs/redis/sdks/agentkit/mcp-events) into +`onSettle`: + +```ts +import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; + +const taskFinished = taskFinishedEvent(events); + +export const tasks = createTaskLayer({ store, dispatcher, onSettle: taskFinished.onSettle }); +``` + ## Why tools, not the Tasks extension? The 2026-07-28 MCP specification defines a [Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) @@ -87704,7 +88177,7 @@ once clients support it. ## Example -The [MCP Tasks demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-tasks-demo) is a +The [MCP toolkit demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-toolkit-demo) is a Next.js app that runs the same tool on QStash and on Workflow, with a live log of every MCP request. # TanStack AI Persistence, Resumable Streams & Memory with Redis diff --git a/llms.txt b/llms.txt index 7b96eb8ba..32a8c92d2 100644 --- a/llms.txt +++ b/llms.txt @@ -314,7 +314,8 @@ Search these docs: GET https://upstash.com/docs/search?q= returns JSON { - [Schedules](https://upstash.com/docs/qstash/sdks/ts/examples/schedules.md) - [URL Groups](https://upstash.com/docs/qstash/sdks/ts/examples/url-groups.md) - [Getting Started](https://upstash.com/docs/qstash/sdks/ts/gettingstarted.md) -- [Long-Running MCP Tools with QStash](https://upstash.com/docs/qstash/sdks/ts/mcp-tasks.md): Run long MCP tool calls durably on QStash with @upstash/mcp-tasks. +- [MCP Events with QStash](https://upstash.com/docs/qstash/sdks/ts/mcp-events.md): Deliver MCP Events webhooks durably through QStash with @upstash/mcp-toolkit. +- [Long-Running MCP Tools with QStash](https://upstash.com/docs/qstash/sdks/ts/mcp-tasks.md): Run long MCP tool calls durably on QStash with @upstash/mcp-toolkit. - [Overview](https://upstash.com/docs/qstash/sdks/ts/overview.md) - [Channels](https://upstash.com/docs/realtime/features/channels.md) - [Client-Side Usage](https://upstash.com/docs/realtime/features/client-side.md) @@ -742,6 +743,8 @@ Search these docs: GET https://upstash.com/docs/search?q= returns JSON { - [Vercel AI SDK Memory, RAG & Chat History with Redis](https://upstash.com/docs/redis/sdks/agentkit/ai-sdk.md): Add long-term memory, RAG, and chat history to the Vercel AI SDK with Upstash Redis — drop-in tools for generateText and streamText, no separate vector database. - [Memory, Chat History, RAG, Rate Limiting & Sandboxes for the Vercel Eve Agent Framework](https://upstash.com/docs/redis/sdks/agentkit/eve.md): Add long-term memory, searchable chat history, RAG, rate limiting, tool caching, and sandboxes to Vercel's Eve agent framework with Upstash Redis — no separate vector database. - [Memory, Chat History, RAG, Rate Limiting & Sandboxes for the Vercel Eve Agent Framework](https://upstash.com/docs/redis/sdks/agentkit/eve.md): Add long-term memory, searchable chat history, RAG, rate limiting, tool caching, and sandboxes to Vercel's Eve agent framework with Upstash Redis — no separate vector database. +- [MCP Events with Redis and QStash](https://upstash.com/docs/redis/sdks/agentkit/mcp-events.md): Let agents subscribe to events on your MCP server and get a signed webhook when one happens. Subscriptions live in Upstash Redis and deliveries are retried by QStash. +- [MCP Events with Redis and QStash](https://upstash.com/docs/redis/sdks/agentkit/mcp-events.md): Let agents subscribe to events on your MCP server and get a signed webhook when one happens. Subscriptions live in Upstash Redis and deliveries are retried by QStash. - [Long-Running MCP Tools with Redis and QStash](https://upstash.com/docs/redis/sdks/agentkit/mcp-tasks.md): Durable long-running tools for MCP servers: a tool answers with a task id at once, the work runs on QStash or Upstash Workflow, and the model polls for the result. Works in every MCP client. - [Long-Running MCP Tools with Redis and QStash](https://upstash.com/docs/redis/sdks/agentkit/mcp-tasks.md): Durable long-running tools for MCP servers: a tool answers with a task id at once, the work runs on QStash or Upstash Workflow, and the model polls for the result. Works in every MCP client. - [TanStack AI Persistence, Resumable Streams & Memory with Redis](https://upstash.com/docs/redis/sdks/agentkit/tanstack-ai.md): Production backends for TanStack AI on Upstash Redis — chat persistence, resumable streams, distributed locks, long-term memory, tool caching, rate limiting, and RAG tools. diff --git a/qstash/sdks/ts/mcp-events.mdx b/qstash/sdks/ts/mcp-events.mdx new file mode 100644 index 000000000..16d512510 --- /dev/null +++ b/qstash/sdks/ts/mcp-events.mdx @@ -0,0 +1,9 @@ +--- +title: "MCP Events with QStash" +sidebarTitle: "MCP Events" +description: "Deliver MCP Events webhooks durably through QStash with @upstash/mcp-toolkit." +--- + + + MCP Events for your server: subscriptions in Upstash Redis, signed webhook deliveries retried by QStash. + diff --git a/qstash/sdks/ts/mcp-tasks.mdx b/qstash/sdks/ts/mcp-tasks.mdx index e46cbf377..4c73baea1 100644 --- a/qstash/sdks/ts/mcp-tasks.mdx +++ b/qstash/sdks/ts/mcp-tasks.mdx @@ -1,7 +1,7 @@ --- title: "Long-Running MCP Tools with QStash" sidebarTitle: "MCP Tasks" -description: "Run long MCP tool calls durably on QStash with @upstash/mcp-tasks." +description: "Run long MCP tool calls durably on QStash with @upstash/mcp-toolkit." --- diff --git a/redis/sdks/agentkit/mcp-events.mdx b/redis/sdks/agentkit/mcp-events.mdx new file mode 100644 index 000000000..02af55c0c --- /dev/null +++ b/redis/sdks/agentkit/mcp-events.mdx @@ -0,0 +1,220 @@ +--- +title: "MCP Events with Redis and QStash" +sidebarTitle: "MCP Events" +description: "Let agents subscribe to events on your MCP server and get a signed webhook when one happens. Subscriptions live in Upstash Redis and deliveries are retried by QStash." +--- + +Most agents only act when someone messages them. [MCP Events](https://github.com/modelcontextprotocol/experimental-ext-triggers-events) +lets an agent wait for something instead: the host subscribes to an event on your MCP server, and +when it happens your server POSTs a signed webhook that wakes the agent up. + +`@upstash/mcp-toolkit/events` implements the server side. You define events with typed payloads and +call `emit` when something happens. The toolkit handles subscriptions, callback verification, +signing and retries: subscriptions live in Upstash Redis, and every delivery goes through +[QStash](/qstash/overall/getstarted), which retries failed webhooks with backoff. + + + MCP Events is a draft. As of October 2026, ChatGPT is the host that subscribes to it (webhook + delivery, in Work chats), following [OpenAI's MCP Events guide](https://developers.openai.com/plugins/build/mcp-events). + Claude Code, Cursor and OpenCode don't subscribe yet. + + +```bash +npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +``` + +## Quickstart + +Three files in a Next.js app. + +### 1. Define the event layer and your events + +```ts lib/events.ts +import { createEventLayer } from "@upstash/mcp-toolkit/events"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events/upstash"; +import * as z from "zod"; + +export const events = createEventLayer({ + store: new RedisSubscriptionStore(), + delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), + // Receives the AuthInfo your auth middleware attached to the request. + principal: (auth) => auth?.extra?.userId as string | undefined, +}); + +export const commentCreated = events.define("comment.created", { + description: "A new review comment was added to a document.", + // What a subscriber filters on. + input: z.object({ documentId: z.string() }), + // What every delivery carries. + payload: z.object({ documentId: z.string(), commentId: z.string(), text: z.string() }), + // Optional: who may subscribe with these arguments. + authorize: (args, { principal }) => canRead(principal, args.documentId), +}); +``` + +### 2. Register the events on your MCP server + +```ts lib/server.ts +import { McpServer } from "@modelcontextprotocol/server"; +import { events } from "./events"; + +export function createServer() { + const server = new McpServer({ name: "docs", version: "1.0.0" }); + // Adds events/list, events/subscribe and events/unsubscribe next to your tools. + events.register(server); + return server; +} +``` + +Serve it with `createMcpHandler(() => createServer())` as usual. Nothing else changes on the MCP +endpoint. + +### 3. Add the delivery endpoint + +```ts app/api/events/route.ts +import { events } from "../../../lib/events"; + +export const POST = events.createDeliveryHandler(); +``` + +QStash delivers each event here. The handler verifies the QStash signature, signs the envelope +with the subscriber's secret, POSTs it to the host, and answers `500` when the host's callback +failed so QStash retries. + +### Emit + +Call `emit` wherever the change happens: a route, a webhook from your own app, a background job. + +```ts +await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); +``` + +`emit` is typed by the `payload` schema and validates against it. It returns the event id and how +many subscriptions matched. + +### Environment variables + +```bash .env +UPSTASH_REDIS_REST_URL=... +UPSTASH_REDIS_REST_TOKEN=... +QSTASH_TOKEN=... +QSTASH_CURRENT_SIGNING_KEY=... +QSTASH_NEXT_SIGNING_KEY=... +# Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 +MCP_EVENTS_SECRET_KEY=... +# Where QStash delivers events. Must be publicly reachable. +APP_URL=https://your-app.vercel.app +``` + +## What happens on the wire + +1. The host calls `events/subscribe` with the event name, arguments, its callback URL and a + `whsec_` signing secret. +2. Your server POSTs a signed verification challenge to the callback and requires it echoed back, + then stores the subscription and answers with its `id` and a `refreshBefore` time. +3. When you `emit`, every matching subscription gets a POST with the event envelope and + [Standard Webhooks](https://www.standardwebhooks.com/) headers: + +```json +{ + "eventId": "evt_7c1…", + "name": "comment.created", + "timestamp": "2026-10-07T12:05:00Z", + "data": { "documentId": "doc_123", "commentId": "c_9", "text": "Ship it?" }, + "cursor": null +} +``` + +The event id stays the same across retries, so the host can drop duplicates. The signature is +computed fresh on every attempt. + +## Matching + +A subscription matches when every argument it gave equals the emitted value. Emitting +`{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of +`{ repo: "a", branch: "main" }`, and of `{}`. + +By default the values come from the payload fields named in the input schema. Pass options to +control it: + +```ts +await commentCreated.emit(payload, { + args: { documentId }, // match on these values instead + owner: userId, // only this user's subscriptions + eventId: comment.id, // emitting the same id twice is deduplicated +}); +``` + +For conditions that exact matching can't express, add `match: (args, payload) => boolean` to the +event definition. + +## Task finished events + +If you use [MCP Tasks](/redis/sdks/agentkit/mcp-tasks), a `task.finished` event lets an +event-capable host stop polling `task_status`: + +```ts +import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; + +const taskFinished = taskFinishedEvent(events); + +export const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); +``` + +It fires once when a task completes, fails or is cancelled, and only to subscriptions created by +the task's owner. A host subscribes with no arguments for every task, or with `taskId` for one. The +payload includes the result when it fits in the 256 KiB envelope. + +## Security + +- **Callback URLs** must be `https` on a public host. `localhost`, `.internal` names, private and + link-local IPs, and credentials in the URL are refused, and redirects are never followed. +- **Verification:** a subscription is stored only after the callback echoes a signed challenge. + Failures return error `-32015` with a `reason` such as `challenge_failed` or `timeout`. +- **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret + skips the challenge; a new secret verifies again. +- **Authorization:** `authorize` runs on every subscribe and refresh. + + + `allowInsecureCallbacks: true` accepts `http://` and private addresses. Use it only for local + development, never in production. + + +## Host responses + +| Callback answers | What happens | +| --- | --- | +| `2xx` | Delivered. | +| `410 Gone` | The subscription is deleted. | +| `413` | The event is dropped, not retried. | +| Anything else, or no answer | QStash retries with backoff. | + +## Options + +**`createEventLayer`** + +| Option | Default | | +| --- | --- | --- | +| `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | +| `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | +| `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required. | +| `principal` | none | `(auth) => string \| undefined`. Part of the subscription id, passed to `authorize`. | +| `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | +| `defaults.maxTtlMs` | 30 days | The most the server grants | +| `allowInsecureCallbacks` | `false` | Local development only | +| `timeoutMs` | 10 seconds | Timeout for each POST to a callback | + +**`define` config:** `description` and `payload`, plus optional `title`, `input`, `authorize` and +`match`. + +**`QStashDelivery`:** `url` (required), `retries` (default 3), `retryDelay`, `headers`, `qstash`, +`receiver`. + +**`RedisSubscriptionStore`:** `redis`, `prefix` (default `mcp-events:`). + +Poll and stream delivery modes and replay cursors from the draft aren't implemented. + +## Example + +The [MCP toolkit demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-toolkit-demo) +emits `task.finished` and includes a local receiver that verifies each signed delivery. diff --git a/redis/sdks/agentkit/mcp-tasks.mdx b/redis/sdks/agentkit/mcp-tasks.mdx index a30b7cbcb..203c91b86 100644 --- a/redis/sdks/agentkit/mcp-tasks.mdx +++ b/redis/sdks/agentkit/mcp-tasks.mdx @@ -7,7 +7,7 @@ description: "Durable long-running tools for MCP servers: a tool answers with a An MCP tool call is a request that waits for its response. When the work takes minutes, something gives up first: the client's tool timeout, your serverless function's limit, or the model. -`@upstash/mcp-tasks` turns a slow tool into a task. The tool answers immediately with a task id, the +`@upstash/mcp-toolkit/tasks` turns a slow tool into a task. The tool answers immediately with a task id, the work runs on [QStash](/qstash/overall/getstarted) or [Upstash Workflow](/workflow/getstarted) so it survives restarts and time limits, and the task record lives in Upstash Redis. The model checks on it with a shared `task_status` tool and can stop it with `task_cancel`. @@ -16,7 +16,7 @@ These are ordinary MCP tools, so they work in every client today: Claude Code, C OpenCode and ChatGPT. No client capability is required. ```bash -npm install @upstash/mcp-tasks @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod ``` ## Quickstart @@ -27,8 +27,8 @@ Three files in a Next.js app. ```ts lib/tasks.ts import { McpServer } from "@modelcontextprotocol/server"; -import { createTaskLayer } from "@upstash/mcp-tasks"; -import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; +import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; import * as z from "zod"; // Where the task record lives, and how the work is delivered. @@ -209,7 +209,7 @@ For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, are replayed from a journal instead of re-run, so a task can run for as long as it needs: ```ts lib/tasks.ts -import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-tasks/upstash"; +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; import type { WorkflowContext } from "@upstash/workflow"; export const tasks = createTaskLayer({ @@ -249,10 +249,25 @@ Put side effects inside `task.run`. Code outside a step runs again on every invo | `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | | `principal` | none | `(auth) => string \| undefined`. Scopes tasks to their caller. | | `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | +| `onSettle` | none | `(task) => void`. Called once when a task completes, fails or is cancelled. | **`registerTask` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, `pollIntervalMs`, `queuedMessage`, `completedMessage` and `idempotencyKey`. +## Push instead of poll + +Hosts that support MCP Events, ChatGPT today, can get a webhook when a task finishes instead of +polling. Wire the `task.finished` event from [MCP Events](/redis/sdks/agentkit/mcp-events) into +`onSettle`: + +```ts +import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; + +const taskFinished = taskFinishedEvent(events); + +export const tasks = createTaskLayer({ store, dispatcher, onSettle: taskFinished.onSettle }); +``` + ## Why tools, not the Tasks extension? The 2026-07-28 MCP specification defines a [Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) @@ -265,5 +280,5 @@ once clients support it. ## Example -The [MCP Tasks demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-tasks-demo) is a +The [MCP toolkit demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-toolkit-demo) is a Next.js app that runs the same tool on QStash and on Workflow, with a live log of every MCP request. From 76df372006cfdddf0068760c704dd11865a060fc Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 09:34:42 +0000 Subject: [PATCH 03/16] DX-3022: docs: single /events import, users and subscriptions section, Deploy Watch example Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK --- llms-full.txt | 98 ++++++++++++++++++++++++++++-- redis/sdks/agentkit/mcp-events.mdx | 49 ++++++++++++++- 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index c034169c0..a2bb12e67 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -87210,7 +87210,7 @@ Three files in a Next.js app. ```ts lib/events.ts import { createEventLayer } from "@upstash/mcp-toolkit/events"; -import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events/upstash"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; import * as z from "zod"; export const events = createEventLayer({ @@ -87327,6 +87327,49 @@ await commentCreated.emit(payload, { For conditions that exact matching can't express, add `match: (args, payload) => boolean` to the event definition. +## Users and subscriptions + +Two things decide who gets an event: the callback URL says **where** it goes, and the principal +says **who** may receive it. + +**The host routes to its user.** Every subscription carries a callback URL and signing secret that +the host generated for it. ChatGPT, for example, sends a unique +`https://connectors.api.openai.com/webhook/mcp-events/` per monitor. Posting to that URL +reaches the right user's agent, so your server never needs to know who the host user is. Two users +subscribing to the same event produce two subscriptions and two deliveries. + +**Your server decides who may subscribe.** `events/subscribe` arrives with the same auth as any +other MCP request. `principal(auth)` turns it into an owner id, which is stored on the +subscription and becomes part of its id: + +```ts +const events = createEventLayer({ + store, + delivery, + principal: (auth) => auth?.extra?.userId as string | undefined, +}); + +const deployFinished = events.define("deploy.finished", { + description: "A deploy finished.", + input: z.object({ service: z.string().optional() }), + payload: Deploy, + // Runs on every subscribe and refresh. + authorize: (args, { principal }) => canSeeService(principal, args.service), +}); +``` + +* `emit(payload)` reaches every matching subscription. Use it for shared events, like deploys. +* `emit(payload, { owner: userId })` reaches only that user's subscriptions. Use it for personal + events, like "your export finished". +* Unsubscribing recomputes the id from the caller's principal, so only the owner can remove a + subscription. + + + Without `principal`, every subscription is anonymous: anyone who can reach the server can + subscribe to any event. Deliveries still reach the right host user, but nothing limits what + they watch. + + ## Task finished events If you use [MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks), a `task.finished` event lets an @@ -87396,7 +87439,9 @@ Poll and stream delivery modes and replay cursors from the draft aren't implemen ## Example The [MCP toolkit demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-toolkit-demo) -emits `task.finished` and includes a local receiver that verifies each signed delivery. +includes Deploy Watch, an events-only server with a `deploy.finished` event filtered by +environment, plus a local receiver that verifies each signed delivery. We tested it end to end with +ChatGPT monitors. # MCP Events with Redis and QStash Source: https://upstash.com/docs/redis/sdks/agentkit/mcp-events @@ -87428,7 +87473,7 @@ Three files in a Next.js app. ```ts lib/events.ts import { createEventLayer } from "@upstash/mcp-toolkit/events"; -import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events/upstash"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; import * as z from "zod"; export const events = createEventLayer({ @@ -87545,6 +87590,49 @@ await commentCreated.emit(payload, { For conditions that exact matching can't express, add `match: (args, payload) => boolean` to the event definition. +## Users and subscriptions + +Two things decide who gets an event: the callback URL says **where** it goes, and the principal +says **who** may receive it. + +**The host routes to its user.** Every subscription carries a callback URL and signing secret that +the host generated for it. ChatGPT, for example, sends a unique +`https://connectors.api.openai.com/webhook/mcp-events/` per monitor. Posting to that URL +reaches the right user's agent, so your server never needs to know who the host user is. Two users +subscribing to the same event produce two subscriptions and two deliveries. + +**Your server decides who may subscribe.** `events/subscribe` arrives with the same auth as any +other MCP request. `principal(auth)` turns it into an owner id, which is stored on the +subscription and becomes part of its id: + +```ts +const events = createEventLayer({ + store, + delivery, + principal: (auth) => auth?.extra?.userId as string | undefined, +}); + +const deployFinished = events.define("deploy.finished", { + description: "A deploy finished.", + input: z.object({ service: z.string().optional() }), + payload: Deploy, + // Runs on every subscribe and refresh. + authorize: (args, { principal }) => canSeeService(principal, args.service), +}); +``` + +* `emit(payload)` reaches every matching subscription. Use it for shared events, like deploys. +* `emit(payload, { owner: userId })` reaches only that user's subscriptions. Use it for personal + events, like "your export finished". +* Unsubscribing recomputes the id from the caller's principal, so only the owner can remove a + subscription. + + + Without `principal`, every subscription is anonymous: anyone who can reach the server can + subscribe to any event. Deliveries still reach the right host user, but nothing limits what + they watch. + + ## Task finished events If you use [MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks), a `task.finished` event lets an @@ -87614,7 +87702,9 @@ Poll and stream delivery modes and replay cursors from the draft aren't implemen ## Example The [MCP toolkit demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-toolkit-demo) -emits `task.finished` and includes a local receiver that verifies each signed delivery. +includes Deploy Watch, an events-only server with a `deploy.finished` event filtered by +environment, plus a local receiver that verifies each signed delivery. We tested it end to end with +ChatGPT monitors. # Long-Running MCP Tools with Redis and QStash Source: https://upstash.com/docs/redis/sdks/agentkit/mcp-tasks diff --git a/redis/sdks/agentkit/mcp-events.mdx b/redis/sdks/agentkit/mcp-events.mdx index 02af55c0c..03bdc62aa 100644 --- a/redis/sdks/agentkit/mcp-events.mdx +++ b/redis/sdks/agentkit/mcp-events.mdx @@ -31,7 +31,7 @@ Three files in a Next.js app. ```ts lib/events.ts import { createEventLayer } from "@upstash/mcp-toolkit/events"; -import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events/upstash"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; import * as z from "zod"; export const events = createEventLayer({ @@ -148,6 +148,49 @@ await commentCreated.emit(payload, { For conditions that exact matching can't express, add `match: (args, payload) => boolean` to the event definition. +## Users and subscriptions + +Two things decide who gets an event: the callback URL says **where** it goes, and the principal +says **who** may receive it. + +**The host routes to its user.** Every subscription carries a callback URL and signing secret that +the host generated for it. ChatGPT, for example, sends a unique +`https://connectors.api.openai.com/webhook/mcp-events/` per monitor. Posting to that URL +reaches the right user's agent, so your server never needs to know who the host user is. Two users +subscribing to the same event produce two subscriptions and two deliveries. + +**Your server decides who may subscribe.** `events/subscribe` arrives with the same auth as any +other MCP request. `principal(auth)` turns it into an owner id, which is stored on the +subscription and becomes part of its id: + +```ts +const events = createEventLayer({ + store, + delivery, + principal: (auth) => auth?.extra?.userId as string | undefined, +}); + +const deployFinished = events.define("deploy.finished", { + description: "A deploy finished.", + input: z.object({ service: z.string().optional() }), + payload: Deploy, + // Runs on every subscribe and refresh. + authorize: (args, { principal }) => canSeeService(principal, args.service), +}); +``` + +- `emit(payload)` reaches every matching subscription. Use it for shared events, like deploys. +- `emit(payload, { owner: userId })` reaches only that user's subscriptions. Use it for personal + events, like "your export finished". +- Unsubscribing recomputes the id from the caller's principal, so only the owner can remove a + subscription. + + + Without `principal`, every subscription is anonymous: anyone who can reach the server can + subscribe to any event. Deliveries still reach the right host user, but nothing limits what + they watch. + + ## Task finished events If you use [MCP Tasks](/redis/sdks/agentkit/mcp-tasks), a `task.finished` event lets an @@ -217,4 +260,6 @@ Poll and stream delivery modes and replay cursors from the draft aren't implemen ## Example The [MCP toolkit demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-toolkit-demo) -emits `task.finished` and includes a local receiver that verifies each signed delivery. +includes Deploy Watch, an events-only server with a `deploy.finished` event filtered by +environment, plus a local receiver that verifies each signed delivery. We tested it end to end with +ChatGPT monitors. From 6476cb43fc426acae15218dae91f0aa80fac96b2 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 11:44:38 +0000 Subject: [PATCH 04/16] DX-3022: docs: address the review - tasks.define/register, required principal and signing keys in every snippet - events: secretKey shown, every emit names owner or owners, a {} subscription only hears about its own user, one generic -32015 for failed verification, stricter callback checks, 489 non-retryable for bad deliveries, redirects dropped - tasks: QStash work is bound by the function limit; Workflow needs the type argument, steps and a longer ttlMs; task_status prepends a status line; data at rest note; a coherent task.finished example, untested with ChatGPT Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK --- llms-full.txt | 380 +++++++++++++++++------------ redis/sdks/agentkit/mcp-events.mdx | 64 +++-- redis/sdks/agentkit/mcp-tasks.mdx | 126 +++++----- 3 files changed, 327 insertions(+), 243 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index a2bb12e67..01c8acb8d 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -87209,13 +87209,14 @@ Three files in a Next.js app. ### 1. Define the event layer and your events ```ts lib/events.ts -import { createEventLayer } from "@upstash/mcp-toolkit/events"; -import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; +import { createEventLayer, QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; import * as z from "zod"; export const events = createEventLayer({ store: new RedisSubscriptionStore(), delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), + // Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 + secretKey: process.env.MCP_EVENTS_SECRET_KEY, // Receives the AuthInfo your auth middleware attached to the request. principal: (auth) => auth?.extra?.userId as string | undefined, }); @@ -87258,18 +87259,22 @@ export const POST = events.createDeliveryHandler(); QStash delivers each event here. The handler verifies the QStash signature, signs the envelope with the subscriber's secret, POSTs it to the host, and answers `500` when the host's callback -failed so QStash retries. +failed so QStash retries. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`. ### Emit Call `emit` wherever the change happens: a route, a webhook from your own app, a background job. +Every `emit` names who may receive it, `owner` for one user or `owners` for several: ```ts -await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); +await commentCreated.emit( + { documentId: "doc_123", commentId: "c_9", text: "Ship it?" }, + { owners: await membersOf("doc_123") }, +); ``` -`emit` is typed by the `payload` schema and validates against it. It returns the event id and how -many subscriptions matched. +`emit` is typed by the `payload` schema and validates against it. It reaches only the named users' +matching subscriptions, and returns the event id and how many subscriptions matched. ### Environment variables @@ -87289,9 +87294,9 @@ APP_URL=https://your-app.vercel.app 1. The host calls `events/subscribe` with the event name, arguments, its callback URL and a `whsec_` signing secret. -2. Your server POSTs a signed verification challenge to the callback and requires it echoed back, - then stores the subscription and answers with its `id` and a `refreshBefore` time. -3. When you `emit`, every matching subscription gets a POST with the event envelope and +2. For a new callback or secret, your server POSTs a signed verification challenge and requires it + echoed back, then stores the subscription and answers with its `id` and a `refreshBefore` time. +3. When you `emit`, every matching subscription of the named owners gets a POST with the event envelope and [Standard Webhooks](https://www.standardwebhooks.com/) headers: ```json @@ -87318,8 +87323,8 @@ control it: ```ts await commentCreated.emit(payload, { + owner: userId, // or owners: [...]; required args: { documentId }, // match on these values instead - owner: userId, // only this user's subscriptions eventId: comment.id, // emitting the same id twice is deduplicated }); ``` @@ -87338,7 +87343,7 @@ the host generated for it. ChatGPT, for example, sends a unique reaches the right user's agent, so your server never needs to know who the host user is. Two users subscribing to the same event produce two subscriptions and two deliveries. -**Your server decides who may subscribe.** `events/subscribe` arrives with the same auth as any +**Your server decides who receives what.** `events/subscribe` arrives with the same auth as any other MCP request. `principal(auth)` turns it into an owner id, which is stored on the subscription and becomes part of its id: @@ -87358,16 +87363,18 @@ const deployFinished = events.define("deploy.finished", { }); ``` -* `emit(payload)` reaches every matching subscription. Use it for shared events, like deploys. -* `emit(payload, { owner: userId })` reaches only that user's subscriptions. Use it for personal - events, like "your export finished". +* Every `emit` names its recipients. `emit(payload, { owner: userId })` reaches one user's + subscriptions, for personal events like "your export finished". `emit(payload, { owners })` + reaches several, for shared ones like a deploy everyone on the team may see. +* So a subscription with no arguments still only hears about its own user's events. `authorize` + adds a per-argument check on top. * Unsubscribing recomputes the id from the caller's principal, so only the owner can remove a subscription. - Without `principal`, every subscription is anonymous: anyone who can reach the server can - subscribe to any event. Deliveries still reach the right host user, but nothing limits what - they watch. + `principal` is required, and there are no anonymous subscriptions: when it returns `undefined`, + subscribe and unsubscribe are refused with reason `not_authenticated`, before any challenge is + sent. A server with no users of its own passes `principal: () => "local"`. ## Task finished events @@ -87384,18 +87391,24 @@ export const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: t ``` It fires once when a task completes, fails or is cancelled, and only to subscriptions created by -the task's owner. A host subscribes with no arguments for every task, or with `taskId` for one. The +the task's owner. We haven't tested it with ChatGPT yet. A host subscribes with no arguments for every task, or with `taskId` for one. The payload includes the result when it fits in the 256 KiB envelope. ## Security -* **Callback URLs** must be `https` on a public host. `localhost`, `.internal` names, private and - link-local IPs, and credentials in the URL are refused, and redirects are never followed. +* **Callback URLs** must be `https` on a public host. `localhost`, `.local` and `.internal` names + (trailing dots included), private, link-local, reserved and documentation IPs (also when embedded + in IPv6), and credentials in the URL are refused, and redirects are never followed. The checks don't + resolve DNS, so add egress filtering in production. * **Verification:** a subscription is stored only after the callback echoes a signed challenge. - Failures return error `-32015` with a `reason` such as `challenge_failed` or `timeout`. + Every failure returns the same error `-32015`, so a subscriber can't probe your network; the + detail goes to your server logs. * **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret skips the challenge; a new secret verifies again. * **Authorization:** `authorize` runs on every subscribe and refresh. +* **Delivery endpoint:** verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` variables (or + a `receiver` you pass). Without them it throws on the first request; it never sends unverified + deliveries. `allowInsecureCallbacks: true` accepts `http://` and private addresses. Use it only for local @@ -87408,7 +87421,7 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | | `2xx` | Delivered. | | `410 Gone` | The subscription is deleted. | -| `413` | The event is dropped, not retried. | +| `413`, or a redirect | The event is dropped, not retried. | | Anything else, or no answer | QStash retries with backoff. | ## Options @@ -87419,8 +87432,8 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | --- | | `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | | `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required. | -| `principal` | none | `(auth) => string \| undefined`. Part of the subscription id, passed to `authorize`. | +| `principal` | required | `(auth) => string \| undefined`. Owns each subscription; `undefined` refuses. | +| `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | | `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | | `defaults.maxTtlMs` | 30 days | The most the server grants | | `allowInsecureCallbacks` | `false` | Local development only | @@ -87441,7 +87454,8 @@ Poll and stream delivery modes and replay cursors from the draft aren't implemen The [MCP toolkit demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-toolkit-demo) includes Deploy Watch, an events-only server with a `deploy.finished` event filtered by environment, plus a local receiver that verifies each signed delivery. We tested it end to end with -ChatGPT monitors. +ChatGPT monitors. It needs `MCP_EVENTS_SECRET_KEY` set, and its deploy-report route is +unauthenticated so the demo is easy to drive; a real server must authenticate the code that emits. # MCP Events with Redis and QStash Source: https://upstash.com/docs/redis/sdks/agentkit/mcp-events @@ -87472,13 +87486,14 @@ Three files in a Next.js app. ### 1. Define the event layer and your events ```ts lib/events.ts -import { createEventLayer } from "@upstash/mcp-toolkit/events"; -import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; +import { createEventLayer, QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; import * as z from "zod"; export const events = createEventLayer({ store: new RedisSubscriptionStore(), delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), + // Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 + secretKey: process.env.MCP_EVENTS_SECRET_KEY, // Receives the AuthInfo your auth middleware attached to the request. principal: (auth) => auth?.extra?.userId as string | undefined, }); @@ -87521,18 +87536,22 @@ export const POST = events.createDeliveryHandler(); QStash delivers each event here. The handler verifies the QStash signature, signs the envelope with the subscriber's secret, POSTs it to the host, and answers `500` when the host's callback -failed so QStash retries. +failed so QStash retries. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`. ### Emit Call `emit` wherever the change happens: a route, a webhook from your own app, a background job. +Every `emit` names who may receive it, `owner` for one user or `owners` for several: ```ts -await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); +await commentCreated.emit( + { documentId: "doc_123", commentId: "c_9", text: "Ship it?" }, + { owners: await membersOf("doc_123") }, +); ``` -`emit` is typed by the `payload` schema and validates against it. It returns the event id and how -many subscriptions matched. +`emit` is typed by the `payload` schema and validates against it. It reaches only the named users' +matching subscriptions, and returns the event id and how many subscriptions matched. ### Environment variables @@ -87552,9 +87571,9 @@ APP_URL=https://your-app.vercel.app 1. The host calls `events/subscribe` with the event name, arguments, its callback URL and a `whsec_` signing secret. -2. Your server POSTs a signed verification challenge to the callback and requires it echoed back, - then stores the subscription and answers with its `id` and a `refreshBefore` time. -3. When you `emit`, every matching subscription gets a POST with the event envelope and +2. For a new callback or secret, your server POSTs a signed verification challenge and requires it + echoed back, then stores the subscription and answers with its `id` and a `refreshBefore` time. +3. When you `emit`, every matching subscription of the named owners gets a POST with the event envelope and [Standard Webhooks](https://www.standardwebhooks.com/) headers: ```json @@ -87581,8 +87600,8 @@ control it: ```ts await commentCreated.emit(payload, { + owner: userId, // or owners: [...]; required args: { documentId }, // match on these values instead - owner: userId, // only this user's subscriptions eventId: comment.id, // emitting the same id twice is deduplicated }); ``` @@ -87601,7 +87620,7 @@ the host generated for it. ChatGPT, for example, sends a unique reaches the right user's agent, so your server never needs to know who the host user is. Two users subscribing to the same event produce two subscriptions and two deliveries. -**Your server decides who may subscribe.** `events/subscribe` arrives with the same auth as any +**Your server decides who receives what.** `events/subscribe` arrives with the same auth as any other MCP request. `principal(auth)` turns it into an owner id, which is stored on the subscription and becomes part of its id: @@ -87621,16 +87640,18 @@ const deployFinished = events.define("deploy.finished", { }); ``` -* `emit(payload)` reaches every matching subscription. Use it for shared events, like deploys. -* `emit(payload, { owner: userId })` reaches only that user's subscriptions. Use it for personal - events, like "your export finished". +* Every `emit` names its recipients. `emit(payload, { owner: userId })` reaches one user's + subscriptions, for personal events like "your export finished". `emit(payload, { owners })` + reaches several, for shared ones like a deploy everyone on the team may see. +* So a subscription with no arguments still only hears about its own user's events. `authorize` + adds a per-argument check on top. * Unsubscribing recomputes the id from the caller's principal, so only the owner can remove a subscription. - Without `principal`, every subscription is anonymous: anyone who can reach the server can - subscribe to any event. Deliveries still reach the right host user, but nothing limits what - they watch. + `principal` is required, and there are no anonymous subscriptions: when it returns `undefined`, + subscribe and unsubscribe are refused with reason `not_authenticated`, before any challenge is + sent. A server with no users of its own passes `principal: () => "local"`. ## Task finished events @@ -87647,18 +87668,24 @@ export const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: t ``` It fires once when a task completes, fails or is cancelled, and only to subscriptions created by -the task's owner. A host subscribes with no arguments for every task, or with `taskId` for one. The +the task's owner. We haven't tested it with ChatGPT yet. A host subscribes with no arguments for every task, or with `taskId` for one. The payload includes the result when it fits in the 256 KiB envelope. ## Security -* **Callback URLs** must be `https` on a public host. `localhost`, `.internal` names, private and - link-local IPs, and credentials in the URL are refused, and redirects are never followed. +* **Callback URLs** must be `https` on a public host. `localhost`, `.local` and `.internal` names + (trailing dots included), private, link-local, reserved and documentation IPs (also when embedded + in IPv6), and credentials in the URL are refused, and redirects are never followed. The checks don't + resolve DNS, so add egress filtering in production. * **Verification:** a subscription is stored only after the callback echoes a signed challenge. - Failures return error `-32015` with a `reason` such as `challenge_failed` or `timeout`. + Every failure returns the same error `-32015`, so a subscriber can't probe your network; the + detail goes to your server logs. * **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret skips the challenge; a new secret verifies again. * **Authorization:** `authorize` runs on every subscribe and refresh. +* **Delivery endpoint:** verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` variables (or + a `receiver` you pass). Without them it throws on the first request; it never sends unverified + deliveries. `allowInsecureCallbacks: true` accepts `http://` and private addresses. Use it only for local @@ -87671,7 +87698,7 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | | `2xx` | Delivered. | | `410 Gone` | The subscription is deleted. | -| `413` | The event is dropped, not retried. | +| `413`, or a redirect | The event is dropped, not retried. | | Anything else, or no answer | QStash retries with backoff. | ## Options @@ -87682,8 +87709,8 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | --- | | `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | | `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required. | -| `principal` | none | `(auth) => string \| undefined`. Part of the subscription id, passed to `authorize`. | +| `principal` | required | `(auth) => string \| undefined`. Owns each subscription; `undefined` refuses. | +| `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | | `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | | `defaults.maxTtlMs` | 30 days | The most the server grants | | `allowInsecureCallbacks` | `false` | Local development only | @@ -87704,7 +87731,8 @@ Poll and stream delivery modes and replay cursors from the draft aren't implemen The [MCP toolkit demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-toolkit-demo) includes Deploy Watch, an events-only server with a `deploy.finished` event filtered by environment, plus a local receiver that verifies each signed delivery. We tested it end to end with -ChatGPT monitors. +ChatGPT monitors. It needs `MCP_EVENTS_SECRET_KEY` set, and its deploy-report route is +unauthenticated so the demo is easy to drive; a real server must authenticate the code that emits. # Long-Running MCP Tools with Redis and QStash Source: https://upstash.com/docs/redis/sdks/agentkit/mcp-tasks @@ -87714,11 +87742,12 @@ gives up first: the client's tool timeout, your serverless function's limit, or `@upstash/mcp-toolkit/tasks` turns a slow tool into a task. The tool answers immediately with a task id, the work runs on [QStash](/docs/qstash/overall/getstarted) or [Upstash Workflow](/docs/workflow/getstarted) so it -survives restarts and time limits, and the task record lives in Upstash Redis. The model checks on it +survives restarts and the client's tool timeout, and the task record lives in Upstash Redis. The model checks on it with a shared `task_status` tool and can stop it with `task_cancel`. These are ordinary MCP tools, so they work in every client today: Claude Code, Codex, Cursor, -OpenCode and ChatGPT. No client capability is required. +OpenCode and ChatGPT. No client capability is required. The toolkit sits on top of the official MCP +TypeScript SDK and uses WebCrypto only, so it runs on Node and edge runtimes. ```bash npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod @@ -87728,7 +87757,7 @@ npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @up Three files in a Next.js app. -### 1. Define the task layer and the server +### 1. Define the task layer, the task and the server ```ts lib/tasks.ts import { McpServer } from "@modelcontextprotocol/server"; @@ -87736,41 +87765,40 @@ import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; import * as z from "zod"; -// Where the task record lives, and how the work is delivered. +// Where the task record lives, how the work is delivered, and who is calling. export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + principal: (auth) => auth?.extra?.userId as string | undefined, }); +// Defined at module scope, so every instance knows the handler, including +// an /api/execute instance that never serves an MCP request. +tasks.define( + "generate_report", + { + description: "Generates a report on a topic.", + inputSchema: z.object({ topic: z.string() }), + }, + // The handler: your long-running work. It runs later, in /api/execute, + // when QStash delivers the task — not inside the tool call. + async ({ topic }, task) => { + await task.update("Researching sources"); + const report = await writeReport(topic); + return { content: [{ type: "text", text: report }] }; + }, +); + +// Called per request. Adds generate_report, task_status and task_cancel. export function createServer() { const server = new McpServer({ name: "reports", version: "1.0.0" }); - - // Adds three tools to the server: generate_report, task_status and task_cancel. - tasks.registerTask( - server, - "generate_report", - { - description: "Generates a report on a topic.", - inputSchema: z.object({ topic: z.string() }), - }, - // The handler: your long-running work. It runs later, in /api/execute, - // when QStash delivers the task — not inside the tool call. - async ({ topic }, task) => { - await task.update("Researching sources"); - const report = await writeReport(topic); - return { content: [{ type: "text", text: report }] }; - }, - ); - + tasks.register(server); return server; } - -// Registers the handler in every process, including an /api/execute -// instance that never serves an MCP request. -createServer(); ``` -`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. +`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. `principal` is +required; see [Users](#users). ### 2. Serve the MCP endpoint @@ -87796,7 +87824,12 @@ export const POST = tasks.createExecuteHandler(); This is where QStash delivers each task. The handler it returns verifies the QStash signature, finds the task's handler, runs it and stores the result. It answers `200` when done and `500` to ask QStash -for a retry. +for a retry. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`, since QStash +retries every other non-2xx. + +The signing keys are required. Without `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY` +(or a `receiver` you pass), the endpoint throws on its first request instead of running unverified +deliveries. The same applies to `WorkflowDispatcher`. ### Environment variables @@ -87822,8 +87855,8 @@ APP_URL=https://your-app.vercel.app telling it to call `task_status` in about two seconds. 2. `task_status({ taskId })` returns progress while the task runs, for example `"Researching sources"`. -3. Once the task completes, `task_status` returns your handler's `content`, exactly as if the tool - had run synchronously. +3. Once the task completes, `task_status` returns a one-line status followed by your handler's + `content`. `task_cancel({ taskId })` marks the task `cancelled` and stops any pending delivery. Each response also carries the task object in `structuredContent`: @@ -87867,32 +87900,30 @@ async ({ sources }, task) => { strings. Keep task schemas to plain values, or parse them again inside the handler. -## Multi-user servers +## Users -Without `principal`, anyone holding a task id can read and cancel that task. With it, each task -records who started it, and `task_status` / `task_cancel` only answer for that caller. Another user's -task id reads like an unknown one. +`principal` is required. It receives the `AuthInfo` your auth middleware attached to the request and +returns the caller's id. Each task records who started it, and `task_status` / `task_cancel` only +answer for that caller. Another user's task id reads like an unknown one. -```ts -export const tasks = createTaskLayer({ - store: new RedisTaskStore(), - dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - // Receives the AuthInfo your auth middleware attached to the request. - principal: (auth) => auth?.extra?.userId as string | undefined, -}); -``` +There is no anonymous mode: when `principal` returns `undefined`, the tool call is refused with "Not +authenticated". A server with no users of its own passes `principal: () => "local"`. Key on the user, not `auth.clientId`: the client id identifies the OAuth app, which can be one id shared by every user of a host like ChatGPT. + + Task arguments and results are stored in Redis as plain JSON for the task's `ttlMs`, which is + forever with `ttlMs: null`. Keep secrets out of them, or use a short TTL. + + ## Retries without duplicates Agents retry tool calls, especially ones that seemed to time out. With `idempotencyKey`, a second call from the same caller with the same key returns the existing task instead of starting another: ```ts -tasks.registerTask( - server, +tasks.define( "generate_report", { description: "Generates a report on a topic.", @@ -87911,20 +87942,24 @@ A QStash delivery is one serverless invocation, so the handler has to finish wit function limit. If it is killed, QStash redelivers and the handler starts over. For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, and finished steps -are replayed from a journal instead of re-run, so a task can run for as long as it needs: +are replayed from a journal instead of re-run. Three things change: ```ts lib/tasks.ts import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; import type { WorkflowContext } from "@upstash/workflow"; -export const tasks = createTaskLayer({ +export const tasks = createTaskLayer({ // 1. the type argument store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + principal: (auth) => auth?.extra?.userId as string | undefined, + // 3. a TTL longer than the work. It runs from creation and is never extended; once it passes, + // the record is gone and isCancelled() returns true. + defaults: { ttlMs: 60 * 60 * 1000 }, }); ``` -The handler's `task` then also has the Workflow API, such as `task.run`, `task.sleep` and -`task.call`: +2. The work goes inside `task.run(...)` steps. The handler's `task` has the Workflow API, such as +`task.run`, `task.sleep` and `task.call`: ```ts async ({ topic }, task) => { @@ -87934,7 +87969,8 @@ async ({ topic }, task) => { }; ``` -Put side effects inside `task.run`. Code outside a step runs again on every invocation. +Put side effects inside `task.run`. Code outside a step runs again on every invocation, and each +step is still one invocation, so keep every step within your function limit. | | `QStashDispatcher` | `WorkflowDispatcher` | | --- | --- | --- | @@ -87950,27 +87986,33 @@ Put side effects inside `task.run`. Code outside a step runs again on every invo | --- | --- | --- | | `store` | required | `RedisTaskStore`, or your own `TaskStore` | | `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | +| `principal` | required | `(auth) => string \| undefined`. The caller's id; `undefined` refuses the call. | | `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | | `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | -| `principal` | none | `(auth) => string \| undefined`. Scopes tasks to their caller. | | `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | | `onSettle` | none | `(task) => void`. Called once when a task completes, fails or is cancelled. | -**`registerTask` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, +**`tasks.define(name, config, handler)` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, `pollIntervalMs`, `queuedMessage`, `completedMessage` and `idempotencyKey`. ## Push instead of poll -Hosts that support MCP Events, ChatGPT today, can get a webhook when a task finishes instead of -polling. Wire the `task.finished` event from [MCP Events](/docs/redis/sdks/agentkit/mcp-events) into -`onSettle`: +A host that supports MCP Events can get a webhook when a task finishes instead of polling. Wire the +`task.finished` event from [MCP Events](/docs/redis/sdks/agentkit/mcp-events) into `onSettle`; it fires to +the task's owner only: -```ts +```ts lib/tasks.ts import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; +import { events } from "./events"; // your event layer const taskFinished = taskFinishedEvent(events); -export const tasks = createTaskLayer({ store, dispatcher, onSettle: taskFinished.onSettle }); +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + principal: (auth) => auth?.extra?.userId as string | undefined, + onSettle: taskFinished.onSettle, +}); ``` ## Why tools, not the Tasks extension? @@ -87996,11 +88038,12 @@ gives up first: the client's tool timeout, your serverless function's limit, or `@upstash/mcp-toolkit/tasks` turns a slow tool into a task. The tool answers immediately with a task id, the work runs on [QStash](/docs/qstash/overall/getstarted) or [Upstash Workflow](/docs/workflow/getstarted) so it -survives restarts and time limits, and the task record lives in Upstash Redis. The model checks on it +survives restarts and the client's tool timeout, and the task record lives in Upstash Redis. The model checks on it with a shared `task_status` tool and can stop it with `task_cancel`. These are ordinary MCP tools, so they work in every client today: Claude Code, Codex, Cursor, -OpenCode and ChatGPT. No client capability is required. +OpenCode and ChatGPT. No client capability is required. The toolkit sits on top of the official MCP +TypeScript SDK and uses WebCrypto only, so it runs on Node and edge runtimes. ```bash npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod @@ -88010,7 +88053,7 @@ npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @up Three files in a Next.js app. -### 1. Define the task layer and the server +### 1. Define the task layer, the task and the server ```ts lib/tasks.ts import { McpServer } from "@modelcontextprotocol/server"; @@ -88018,41 +88061,40 @@ import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; import * as z from "zod"; -// Where the task record lives, and how the work is delivered. +// Where the task record lives, how the work is delivered, and who is calling. export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + principal: (auth) => auth?.extra?.userId as string | undefined, }); +// Defined at module scope, so every instance knows the handler, including +// an /api/execute instance that never serves an MCP request. +tasks.define( + "generate_report", + { + description: "Generates a report on a topic.", + inputSchema: z.object({ topic: z.string() }), + }, + // The handler: your long-running work. It runs later, in /api/execute, + // when QStash delivers the task — not inside the tool call. + async ({ topic }, task) => { + await task.update("Researching sources"); + const report = await writeReport(topic); + return { content: [{ type: "text", text: report }] }; + }, +); + +// Called per request. Adds generate_report, task_status and task_cancel. export function createServer() { const server = new McpServer({ name: "reports", version: "1.0.0" }); - - // Adds three tools to the server: generate_report, task_status and task_cancel. - tasks.registerTask( - server, - "generate_report", - { - description: "Generates a report on a topic.", - inputSchema: z.object({ topic: z.string() }), - }, - // The handler: your long-running work. It runs later, in /api/execute, - // when QStash delivers the task — not inside the tool call. - async ({ topic }, task) => { - await task.update("Researching sources"); - const report = await writeReport(topic); - return { content: [{ type: "text", text: report }] }; - }, - ); - + tasks.register(server); return server; } - -// Registers the handler in every process, including an /api/execute -// instance that never serves an MCP request. -createServer(); ``` -`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. +`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. `principal` is +required; see [Users](#users). ### 2. Serve the MCP endpoint @@ -88078,7 +88120,12 @@ export const POST = tasks.createExecuteHandler(); This is where QStash delivers each task. The handler it returns verifies the QStash signature, finds the task's handler, runs it and stores the result. It answers `200` when done and `500` to ask QStash -for a retry. +for a retry. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`, since QStash +retries every other non-2xx. + +The signing keys are required. Without `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY` +(or a `receiver` you pass), the endpoint throws on its first request instead of running unverified +deliveries. The same applies to `WorkflowDispatcher`. ### Environment variables @@ -88104,8 +88151,8 @@ APP_URL=https://your-app.vercel.app telling it to call `task_status` in about two seconds. 2. `task_status({ taskId })` returns progress while the task runs, for example `"Researching sources"`. -3. Once the task completes, `task_status` returns your handler's `content`, exactly as if the tool - had run synchronously. +3. Once the task completes, `task_status` returns a one-line status followed by your handler's + `content`. `task_cancel({ taskId })` marks the task `cancelled` and stops any pending delivery. Each response also carries the task object in `structuredContent`: @@ -88149,32 +88196,30 @@ async ({ sources }, task) => { strings. Keep task schemas to plain values, or parse them again inside the handler. -## Multi-user servers +## Users -Without `principal`, anyone holding a task id can read and cancel that task. With it, each task -records who started it, and `task_status` / `task_cancel` only answer for that caller. Another user's -task id reads like an unknown one. +`principal` is required. It receives the `AuthInfo` your auth middleware attached to the request and +returns the caller's id. Each task records who started it, and `task_status` / `task_cancel` only +answer for that caller. Another user's task id reads like an unknown one. -```ts -export const tasks = createTaskLayer({ - store: new RedisTaskStore(), - dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - // Receives the AuthInfo your auth middleware attached to the request. - principal: (auth) => auth?.extra?.userId as string | undefined, -}); -``` +There is no anonymous mode: when `principal` returns `undefined`, the tool call is refused with "Not +authenticated". A server with no users of its own passes `principal: () => "local"`. Key on the user, not `auth.clientId`: the client id identifies the OAuth app, which can be one id shared by every user of a host like ChatGPT. + + Task arguments and results are stored in Redis as plain JSON for the task's `ttlMs`, which is + forever with `ttlMs: null`. Keep secrets out of them, or use a short TTL. + + ## Retries without duplicates Agents retry tool calls, especially ones that seemed to time out. With `idempotencyKey`, a second call from the same caller with the same key returns the existing task instead of starting another: ```ts -tasks.registerTask( - server, +tasks.define( "generate_report", { description: "Generates a report on a topic.", @@ -88193,20 +88238,24 @@ A QStash delivery is one serverless invocation, so the handler has to finish wit function limit. If it is killed, QStash redelivers and the handler starts over. For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, and finished steps -are replayed from a journal instead of re-run, so a task can run for as long as it needs: +are replayed from a journal instead of re-run. Three things change: ```ts lib/tasks.ts import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; import type { WorkflowContext } from "@upstash/workflow"; -export const tasks = createTaskLayer({ +export const tasks = createTaskLayer({ // 1. the type argument store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + principal: (auth) => auth?.extra?.userId as string | undefined, + // 3. a TTL longer than the work. It runs from creation and is never extended; once it passes, + // the record is gone and isCancelled() returns true. + defaults: { ttlMs: 60 * 60 * 1000 }, }); ``` -The handler's `task` then also has the Workflow API, such as `task.run`, `task.sleep` and -`task.call`: +2. The work goes inside `task.run(...)` steps. The handler's `task` has the Workflow API, such as +`task.run`, `task.sleep` and `task.call`: ```ts async ({ topic }, task) => { @@ -88216,7 +88265,8 @@ async ({ topic }, task) => { }; ``` -Put side effects inside `task.run`. Code outside a step runs again on every invocation. +Put side effects inside `task.run`. Code outside a step runs again on every invocation, and each +step is still one invocation, so keep every step within your function limit. | | `QStashDispatcher` | `WorkflowDispatcher` | | --- | --- | --- | @@ -88232,27 +88282,33 @@ Put side effects inside `task.run`. Code outside a step runs again on every invo | --- | --- | --- | | `store` | required | `RedisTaskStore`, or your own `TaskStore` | | `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | +| `principal` | required | `(auth) => string \| undefined`. The caller's id; `undefined` refuses the call. | | `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | | `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | -| `principal` | none | `(auth) => string \| undefined`. Scopes tasks to their caller. | | `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | | `onSettle` | none | `(task) => void`. Called once when a task completes, fails or is cancelled. | -**`registerTask` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, +**`tasks.define(name, config, handler)` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, `pollIntervalMs`, `queuedMessage`, `completedMessage` and `idempotencyKey`. ## Push instead of poll -Hosts that support MCP Events, ChatGPT today, can get a webhook when a task finishes instead of -polling. Wire the `task.finished` event from [MCP Events](/docs/redis/sdks/agentkit/mcp-events) into -`onSettle`: +A host that supports MCP Events can get a webhook when a task finishes instead of polling. Wire the +`task.finished` event from [MCP Events](/docs/redis/sdks/agentkit/mcp-events) into `onSettle`; it fires to +the task's owner only: -```ts +```ts lib/tasks.ts import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; +import { events } from "./events"; // your event layer const taskFinished = taskFinishedEvent(events); -export const tasks = createTaskLayer({ store, dispatcher, onSettle: taskFinished.onSettle }); +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + principal: (auth) => auth?.extra?.userId as string | undefined, + onSettle: taskFinished.onSettle, +}); ``` ## Why tools, not the Tasks extension? diff --git a/redis/sdks/agentkit/mcp-events.mdx b/redis/sdks/agentkit/mcp-events.mdx index 03bdc62aa..a2a8ffe54 100644 --- a/redis/sdks/agentkit/mcp-events.mdx +++ b/redis/sdks/agentkit/mcp-events.mdx @@ -30,13 +30,14 @@ Three files in a Next.js app. ### 1. Define the event layer and your events ```ts lib/events.ts -import { createEventLayer } from "@upstash/mcp-toolkit/events"; -import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; +import { createEventLayer, QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; import * as z from "zod"; export const events = createEventLayer({ store: new RedisSubscriptionStore(), delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), + // Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 + secretKey: process.env.MCP_EVENTS_SECRET_KEY, // Receives the AuthInfo your auth middleware attached to the request. principal: (auth) => auth?.extra?.userId as string | undefined, }); @@ -79,18 +80,22 @@ export const POST = events.createDeliveryHandler(); QStash delivers each event here. The handler verifies the QStash signature, signs the envelope with the subscriber's secret, POSTs it to the host, and answers `500` when the host's callback -failed so QStash retries. +failed so QStash retries. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`. ### Emit Call `emit` wherever the change happens: a route, a webhook from your own app, a background job. +Every `emit` names who may receive it, `owner` for one user or `owners` for several: ```ts -await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); +await commentCreated.emit( + { documentId: "doc_123", commentId: "c_9", text: "Ship it?" }, + { owners: await membersOf("doc_123") }, +); ``` -`emit` is typed by the `payload` schema and validates against it. It returns the event id and how -many subscriptions matched. +`emit` is typed by the `payload` schema and validates against it. It reaches only the named users' +matching subscriptions, and returns the event id and how many subscriptions matched. ### Environment variables @@ -110,9 +115,9 @@ APP_URL=https://your-app.vercel.app 1. The host calls `events/subscribe` with the event name, arguments, its callback URL and a `whsec_` signing secret. -2. Your server POSTs a signed verification challenge to the callback and requires it echoed back, - then stores the subscription and answers with its `id` and a `refreshBefore` time. -3. When you `emit`, every matching subscription gets a POST with the event envelope and +2. For a new callback or secret, your server POSTs a signed verification challenge and requires it + echoed back, then stores the subscription and answers with its `id` and a `refreshBefore` time. +3. When you `emit`, every matching subscription of the named owners gets a POST with the event envelope and [Standard Webhooks](https://www.standardwebhooks.com/) headers: ```json @@ -139,8 +144,8 @@ control it: ```ts await commentCreated.emit(payload, { + owner: userId, // or owners: [...]; required args: { documentId }, // match on these values instead - owner: userId, // only this user's subscriptions eventId: comment.id, // emitting the same id twice is deduplicated }); ``` @@ -159,7 +164,7 @@ the host generated for it. ChatGPT, for example, sends a unique reaches the right user's agent, so your server never needs to know who the host user is. Two users subscribing to the same event produce two subscriptions and two deliveries. -**Your server decides who may subscribe.** `events/subscribe` arrives with the same auth as any +**Your server decides who receives what.** `events/subscribe` arrives with the same auth as any other MCP request. `principal(auth)` turns it into an owner id, which is stored on the subscription and becomes part of its id: @@ -179,16 +184,18 @@ const deployFinished = events.define("deploy.finished", { }); ``` -- `emit(payload)` reaches every matching subscription. Use it for shared events, like deploys. -- `emit(payload, { owner: userId })` reaches only that user's subscriptions. Use it for personal - events, like "your export finished". +- Every `emit` names its recipients. `emit(payload, { owner: userId })` reaches one user's + subscriptions, for personal events like "your export finished". `emit(payload, { owners })` + reaches several, for shared ones like a deploy everyone on the team may see. +- So a subscription with no arguments still only hears about its own user's events. `authorize` + adds a per-argument check on top. - Unsubscribing recomputes the id from the caller's principal, so only the owner can remove a subscription. - Without `principal`, every subscription is anonymous: anyone who can reach the server can - subscribe to any event. Deliveries still reach the right host user, but nothing limits what - they watch. + `principal` is required, and there are no anonymous subscriptions: when it returns `undefined`, + subscribe and unsubscribe are refused with reason `not_authenticated`, before any challenge is + sent. A server with no users of its own passes `principal: () => "local"`. ## Task finished events @@ -205,18 +212,24 @@ export const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: t ``` It fires once when a task completes, fails or is cancelled, and only to subscriptions created by -the task's owner. A host subscribes with no arguments for every task, or with `taskId` for one. The +the task's owner. We haven't tested it with ChatGPT yet. A host subscribes with no arguments for every task, or with `taskId` for one. The payload includes the result when it fits in the 256 KiB envelope. ## Security -- **Callback URLs** must be `https` on a public host. `localhost`, `.internal` names, private and - link-local IPs, and credentials in the URL are refused, and redirects are never followed. +- **Callback URLs** must be `https` on a public host. `localhost`, `.local` and `.internal` names + (trailing dots included), private, link-local, reserved and documentation IPs (also when embedded + in IPv6), and credentials in the URL are refused, and redirects are never followed. The checks don't + resolve DNS, so add egress filtering in production. - **Verification:** a subscription is stored only after the callback echoes a signed challenge. - Failures return error `-32015` with a `reason` such as `challenge_failed` or `timeout`. + Every failure returns the same error `-32015`, so a subscriber can't probe your network; the + detail goes to your server logs. - **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret skips the challenge; a new secret verifies again. - **Authorization:** `authorize` runs on every subscribe and refresh. +- **Delivery endpoint:** verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` variables (or + a `receiver` you pass). Without them it throws on the first request; it never sends unverified + deliveries. `allowInsecureCallbacks: true` accepts `http://` and private addresses. Use it only for local @@ -229,7 +242,7 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | | `2xx` | Delivered. | | `410 Gone` | The subscription is deleted. | -| `413` | The event is dropped, not retried. | +| `413`, or a redirect | The event is dropped, not retried. | | Anything else, or no answer | QStash retries with backoff. | ## Options @@ -240,8 +253,8 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | --- | | `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | | `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required. | -| `principal` | none | `(auth) => string \| undefined`. Part of the subscription id, passed to `authorize`. | +| `principal` | required | `(auth) => string \| undefined`. Owns each subscription; `undefined` refuses. | +| `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | | `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | | `defaults.maxTtlMs` | 30 days | The most the server grants | | `allowInsecureCallbacks` | `false` | Local development only | @@ -262,4 +275,5 @@ Poll and stream delivery modes and replay cursors from the draft aren't implemen The [MCP toolkit demo](https://github.com/upstash/agentkit/tree/main/examples/mcp-toolkit-demo) includes Deploy Watch, an events-only server with a `deploy.finished` event filtered by environment, plus a local receiver that verifies each signed delivery. We tested it end to end with -ChatGPT monitors. +ChatGPT monitors. It needs `MCP_EVENTS_SECRET_KEY` set, and its deploy-report route is +unauthenticated so the demo is easy to drive; a real server must authenticate the code that emits. diff --git a/redis/sdks/agentkit/mcp-tasks.mdx b/redis/sdks/agentkit/mcp-tasks.mdx index 203c91b86..f6eab0b72 100644 --- a/redis/sdks/agentkit/mcp-tasks.mdx +++ b/redis/sdks/agentkit/mcp-tasks.mdx @@ -9,11 +9,12 @@ gives up first: the client's tool timeout, your serverless function's limit, or `@upstash/mcp-toolkit/tasks` turns a slow tool into a task. The tool answers immediately with a task id, the work runs on [QStash](/qstash/overall/getstarted) or [Upstash Workflow](/workflow/getstarted) so it -survives restarts and time limits, and the task record lives in Upstash Redis. The model checks on it +survives restarts and the client's tool timeout, and the task record lives in Upstash Redis. The model checks on it with a shared `task_status` tool and can stop it with `task_cancel`. These are ordinary MCP tools, so they work in every client today: Claude Code, Codex, Cursor, -OpenCode and ChatGPT. No client capability is required. +OpenCode and ChatGPT. No client capability is required. The toolkit sits on top of the official MCP +TypeScript SDK and uses WebCrypto only, so it runs on Node and edge runtimes. ```bash npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod @@ -23,7 +24,7 @@ npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @up Three files in a Next.js app. -### 1. Define the task layer and the server +### 1. Define the task layer, the task and the server ```ts lib/tasks.ts import { McpServer } from "@modelcontextprotocol/server"; @@ -31,41 +32,40 @@ import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; import * as z from "zod"; -// Where the task record lives, and how the work is delivered. +// Where the task record lives, how the work is delivered, and who is calling. export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + principal: (auth) => auth?.extra?.userId as string | undefined, }); +// Defined at module scope, so every instance knows the handler, including +// an /api/execute instance that never serves an MCP request. +tasks.define( + "generate_report", + { + description: "Generates a report on a topic.", + inputSchema: z.object({ topic: z.string() }), + }, + // The handler: your long-running work. It runs later, in /api/execute, + // when QStash delivers the task — not inside the tool call. + async ({ topic }, task) => { + await task.update("Researching sources"); + const report = await writeReport(topic); + return { content: [{ type: "text", text: report }] }; + }, +); + +// Called per request. Adds generate_report, task_status and task_cancel. export function createServer() { const server = new McpServer({ name: "reports", version: "1.0.0" }); - - // Adds three tools to the server: generate_report, task_status and task_cancel. - tasks.registerTask( - server, - "generate_report", - { - description: "Generates a report on a topic.", - inputSchema: z.object({ topic: z.string() }), - }, - // The handler: your long-running work. It runs later, in /api/execute, - // when QStash delivers the task — not inside the tool call. - async ({ topic }, task) => { - await task.update("Researching sources"); - const report = await writeReport(topic); - return { content: [{ type: "text", text: report }] }; - }, - ); - + tasks.register(server); return server; } - -// Registers the handler in every process, including an /api/execute -// instance that never serves an MCP request. -createServer(); ``` -`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. +`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. `principal` is +required; see [Users](#users). ### 2. Serve the MCP endpoint @@ -91,7 +91,12 @@ export const POST = tasks.createExecuteHandler(); This is where QStash delivers each task. The handler it returns verifies the QStash signature, finds the task's handler, runs it and stores the result. It answers `200` when done and `500` to ask QStash -for a retry. +for a retry. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`, since QStash +retries every other non-2xx. + +The signing keys are required. Without `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY` +(or a `receiver` you pass), the endpoint throws on its first request instead of running unverified +deliveries. The same applies to `WorkflowDispatcher`. ### Environment variables @@ -117,8 +122,8 @@ APP_URL=https://your-app.vercel.app telling it to call `task_status` in about two seconds. 2. `task_status({ taskId })` returns progress while the task runs, for example `"Researching sources"`. -3. Once the task completes, `task_status` returns your handler's `content`, exactly as if the tool - had run synchronously. +3. Once the task completes, `task_status` returns a one-line status followed by your handler's + `content`. `task_cancel({ taskId })` marks the task `cancelled` and stops any pending delivery. Each response also carries the task object in `structuredContent`: @@ -162,32 +167,30 @@ async ({ sources }, task) => { strings. Keep task schemas to plain values, or parse them again inside the handler. -## Multi-user servers +## Users -Without `principal`, anyone holding a task id can read and cancel that task. With it, each task -records who started it, and `task_status` / `task_cancel` only answer for that caller. Another user's -task id reads like an unknown one. +`principal` is required. It receives the `AuthInfo` your auth middleware attached to the request and +returns the caller's id. Each task records who started it, and `task_status` / `task_cancel` only +answer for that caller. Another user's task id reads like an unknown one. -```ts -export const tasks = createTaskLayer({ - store: new RedisTaskStore(), - dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - // Receives the AuthInfo your auth middleware attached to the request. - principal: (auth) => auth?.extra?.userId as string | undefined, -}); -``` +There is no anonymous mode: when `principal` returns `undefined`, the tool call is refused with "Not +authenticated". A server with no users of its own passes `principal: () => "local"`. Key on the user, not `auth.clientId`: the client id identifies the OAuth app, which can be one id shared by every user of a host like ChatGPT. + + Task arguments and results are stored in Redis as plain JSON for the task's `ttlMs`, which is + forever with `ttlMs: null`. Keep secrets out of them, or use a short TTL. + + ## Retries without duplicates Agents retry tool calls, especially ones that seemed to time out. With `idempotencyKey`, a second call from the same caller with the same key returns the existing task instead of starting another: ```ts -tasks.registerTask( - server, +tasks.define( "generate_report", { description: "Generates a report on a topic.", @@ -206,20 +209,24 @@ A QStash delivery is one serverless invocation, so the handler has to finish wit function limit. If it is killed, QStash redelivers and the handler starts over. For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, and finished steps -are replayed from a journal instead of re-run, so a task can run for as long as it needs: +are replayed from a journal instead of re-run. Three things change: ```ts lib/tasks.ts import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; import type { WorkflowContext } from "@upstash/workflow"; -export const tasks = createTaskLayer({ +export const tasks = createTaskLayer({ // 1. the type argument store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + principal: (auth) => auth?.extra?.userId as string | undefined, + // 3. a TTL longer than the work. It runs from creation and is never extended; once it passes, + // the record is gone and isCancelled() returns true. + defaults: { ttlMs: 60 * 60 * 1000 }, }); ``` -The handler's `task` then also has the Workflow API, such as `task.run`, `task.sleep` and -`task.call`: +2. The work goes inside `task.run(...)` steps. The handler's `task` has the Workflow API, such as +`task.run`, `task.sleep` and `task.call`: ```ts async ({ topic }, task) => { @@ -229,7 +236,8 @@ async ({ topic }, task) => { }; ``` -Put side effects inside `task.run`. Code outside a step runs again on every invocation. +Put side effects inside `task.run`. Code outside a step runs again on every invocation, and each +step is still one invocation, so keep every step within your function limit. | | `QStashDispatcher` | `WorkflowDispatcher` | | --- | --- | --- | @@ -245,27 +253,33 @@ Put side effects inside `task.run`. Code outside a step runs again on every invo | --- | --- | --- | | `store` | required | `RedisTaskStore`, or your own `TaskStore` | | `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | +| `principal` | required | `(auth) => string \| undefined`. The caller's id; `undefined` refuses the call. | | `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | | `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | -| `principal` | none | `(auth) => string \| undefined`. Scopes tasks to their caller. | | `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | | `onSettle` | none | `(task) => void`. Called once when a task completes, fails or is cancelled. | -**`registerTask` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, +**`tasks.define(name, config, handler)` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, `pollIntervalMs`, `queuedMessage`, `completedMessage` and `idempotencyKey`. ## Push instead of poll -Hosts that support MCP Events, ChatGPT today, can get a webhook when a task finishes instead of -polling. Wire the `task.finished` event from [MCP Events](/redis/sdks/agentkit/mcp-events) into -`onSettle`: +A host that supports MCP Events can get a webhook when a task finishes instead of polling. Wire the +`task.finished` event from [MCP Events](/redis/sdks/agentkit/mcp-events) into `onSettle`; it fires to +the task's owner only: -```ts +```ts lib/tasks.ts import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; +import { events } from "./events"; // your event layer const taskFinished = taskFinishedEvent(events); -export const tasks = createTaskLayer({ store, dispatcher, onSettle: taskFinished.onSettle }); +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + principal: (auth) => auth?.extra?.userId as string | undefined, + onSettle: taskFinished.onSettle, +}); ``` ## Why tools, not the Tasks extension? From 952996f929c15356ce513c337c358fba3f444290 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 12:00:26 +0000 Subject: [PATCH 05/16] DX-3022: docs: explain AuthInfo, principal receives { auth, request } - the Users section explains what auth (AuthInfo) is and that the MCP route populates it via handler.fetch(request, { authInfo }) after verifying the token - principal receives { auth, request } and may be async; request is for cookie/session apps and is unverified Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK --- llms-full.txt | 186 +++++++++++++++++++++++------ redis/sdks/agentkit/mcp-events.mdx | 15 ++- redis/sdks/agentkit/mcp-tasks.mdx | 78 ++++++++++-- 3 files changed, 225 insertions(+), 54 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 01c8acb8d..243b57a68 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -87217,8 +87217,9 @@ export const events = createEventLayer({ delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), // Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 secretKey: process.env.MCP_EVENTS_SECRET_KEY, - // Receives the AuthInfo your auth middleware attached to the request. - principal: (auth) => auth?.extra?.userId as string | undefined, + // Who is calling. `auth` is the verified token your MCP route passes to the SDK; see + // "Users" in the MCP Tasks docs for how it's populated. + principal: ({ auth }) => auth?.extra?.userId as string | undefined, }); export const commentCreated = events.define("comment.created", { @@ -87344,14 +87345,16 @@ reaches the right user's agent, so your server never needs to know who the host subscribing to the same event produce two subscriptions and two deliveries. **Your server decides who receives what.** `events/subscribe` arrives with the same auth as any -other MCP request. `principal(auth)` turns it into an owner id, which is stored on the +other MCP request: the `AuthInfo` your route verified and passed to `handler.fetch(request, { +authInfo })` (see [Users](/docs/redis/sdks/agentkit/mcp-tasks#users)). `principal({ auth, request })` +turns it into an owner id, which is stored on the subscription and becomes part of its id: ```ts const events = createEventLayer({ store, delivery, - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, }); const deployFinished = events.define("deploy.finished", { @@ -87405,7 +87408,7 @@ payload includes the result when it fits in the 256 KiB envelope. detail goes to your server logs. * **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret skips the challenge; a new secret verifies again. -* **Authorization:** `authorize` runs on every subscribe and refresh. +* **Authorization:** `authorize(args, { principal, auth, request })` runs on every subscribe and refresh. * **Delivery endpoint:** verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` variables (or a `receiver` you pass). Without them it throws on the first request; it never sends unverified deliveries. @@ -87432,7 +87435,7 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | --- | | `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | | `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `principal` | required | `(auth) => string \| undefined`. Owns each subscription; `undefined` refuses. | +| `principal` | required | `({ auth, request }) => string \| undefined`, may be async. Owns each subscription; `undefined` refuses. | | `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | | `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | | `defaults.maxTtlMs` | 30 days | The most the server grants | @@ -87494,8 +87497,9 @@ export const events = createEventLayer({ delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), // Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 secretKey: process.env.MCP_EVENTS_SECRET_KEY, - // Receives the AuthInfo your auth middleware attached to the request. - principal: (auth) => auth?.extra?.userId as string | undefined, + // Who is calling. `auth` is the verified token your MCP route passes to the SDK; see + // "Users" in the MCP Tasks docs for how it's populated. + principal: ({ auth }) => auth?.extra?.userId as string | undefined, }); export const commentCreated = events.define("comment.created", { @@ -87621,14 +87625,16 @@ reaches the right user's agent, so your server never needs to know who the host subscribing to the same event produce two subscriptions and two deliveries. **Your server decides who receives what.** `events/subscribe` arrives with the same auth as any -other MCP request. `principal(auth)` turns it into an owner id, which is stored on the +other MCP request: the `AuthInfo` your route verified and passed to `handler.fetch(request, { +authInfo })` (see [Users](/docs/redis/sdks/agentkit/mcp-tasks#users)). `principal({ auth, request })` +turns it into an owner id, which is stored on the subscription and becomes part of its id: ```ts const events = createEventLayer({ store, delivery, - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, }); const deployFinished = events.define("deploy.finished", { @@ -87682,7 +87688,7 @@ payload includes the result when it fits in the 256 KiB envelope. detail goes to your server logs. * **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret skips the challenge; a new secret verifies again. -* **Authorization:** `authorize` runs on every subscribe and refresh. +* **Authorization:** `authorize(args, { principal, auth, request })` runs on every subscribe and refresh. * **Delivery endpoint:** verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` variables (or a `receiver` you pass). Without them it throws on the first request; it never sends unverified deliveries. @@ -87709,7 +87715,7 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | --- | | `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | | `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `principal` | required | `(auth) => string \| undefined`. Owns each subscription; `undefined` refuses. | +| `principal` | required | `({ auth, request }) => string \| undefined`, may be async. Owns each subscription; `undefined` refuses. | | `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | | `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | | `defaults.maxTtlMs` | 30 days | The most the server grants | @@ -87769,7 +87775,7 @@ import * as z from "zod"; export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, }); // Defined at module scope, so every instance knows the handler, including @@ -87798,7 +87804,7 @@ export function createServer() { ``` `topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. `principal` is -required; see [Users](#users). +required, and `auth` is the verified token your route passes in; see [Users](#users). ### 2. Serve the MCP endpoint @@ -87902,15 +87908,69 @@ async ({ sources }, task) => { ## Users -`principal` is required. It receives the `AuthInfo` your auth middleware attached to the request and -returns the caller's id. Each task records who started it, and `task_status` / `task_cancel` only -answer for that caller. Another user's task id reads like an unknown one. +`principal` is required. It maps each call to a stable caller id, usually your user id. Each task +records who started it, and `task_status` / `task_cancel` only answer for that caller. Another +user's task id reads like an unknown one. -There is no anonymous mode: when `principal` returns `undefined`, the tool call is refused with "Not -authenticated". A server with no users of its own passes `principal: () => "local"`. +`principal` receives `{ auth, request }` and may be async. -Key on the user, not `auth.clientId`: the client id identifies the OAuth app, which can be one id -shared by every user of a host like ChatGPT. +### `auth`: the verified token + +`auth` is the MCP SDK's `AuthInfo`: + +```ts +interface AuthInfo { + token: string; + clientId: string; // the OAuth app, e.g. one id for every ChatGPT user + scopes: string[]; + expiresAt?: number; + extra?: Record; // free-form, filled by your verifier +} +``` + +The SDK never fills it in from request headers. Your MCP route does, after verifying the bearer +token with your OAuth provider, by passing it to the handler: + +```ts app/api/mcp/route.ts +import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createServer } from "../../../lib/tasks"; + +const mcp = createMcpHandler(() => createServer()); + +export async function POST(request: Request) { + // Verify the bearer token with Clerk, WorkOS, Auth0 or your own auth server. + const authInfo = await verifyToken(request); + if (!authInfo) return new Response("Unauthorized", { status: 401 }); + return mcp.fetch(request, { authInfo }); +} +``` + +Put the user id in `extra` when you build `authInfo`, and read it back in `principal`: + +```ts +principal: ({ auth }) => auth?.extra?.userId as string | undefined, +``` + +Because the token was already checked, this is the path to prefer. Key on the user, not +`auth.clientId`: the client id identifies the OAuth app, which can be one id shared by every user of +a host like ChatGPT. + +### `request`: the raw HTTP request + +Apps that authenticate with a cookie or a session, rather than building an `AuthInfo`, can read the +request instead: + +```ts +principal: async ({ request }) => (await getSession(request))?.userId, +``` + +The request is unverified. Check the session yourself, and never trust a header like `x-user-id` +that the caller can set. + +### No anonymous mode + +When `principal` returns `undefined`, the tool call is refused with "Not authenticated". A server +with no users of its own passes `principal: () => "local"`. Task arguments and results are stored in Redis as plain JSON for the task's `ttlMs`, which is @@ -87951,7 +88011,7 @@ import type { WorkflowContext } from "@upstash/workflow"; export const tasks = createTaskLayer({ // 1. the type argument store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, // 3. a TTL longer than the work. It runs from creation and is never extended; once it passes, // the record is gone and isCancelled() returns true. defaults: { ttlMs: 60 * 60 * 1000 }, @@ -87986,7 +88046,7 @@ step is still one invocation, so keep every step within your function limit. | --- | --- | --- | | `store` | required | `RedisTaskStore`, or your own `TaskStore` | | `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | -| `principal` | required | `(auth) => string \| undefined`. The caller's id; `undefined` refuses the call. | +| `principal` | required | `({ auth, request }) => string \| undefined`, may be async. The caller's id; `undefined` refuses the call. | | `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | | `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | | `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | @@ -88010,7 +88070,7 @@ const taskFinished = taskFinishedEvent(events); export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, onSettle: taskFinished.onSettle, }); ``` @@ -88065,7 +88125,7 @@ import * as z from "zod"; export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, }); // Defined at module scope, so every instance knows the handler, including @@ -88094,7 +88154,7 @@ export function createServer() { ``` `topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. `principal` is -required; see [Users](#users). +required, and `auth` is the verified token your route passes in; see [Users](#users). ### 2. Serve the MCP endpoint @@ -88198,15 +88258,69 @@ async ({ sources }, task) => { ## Users -`principal` is required. It receives the `AuthInfo` your auth middleware attached to the request and -returns the caller's id. Each task records who started it, and `task_status` / `task_cancel` only -answer for that caller. Another user's task id reads like an unknown one. +`principal` is required. It maps each call to a stable caller id, usually your user id. Each task +records who started it, and `task_status` / `task_cancel` only answer for that caller. Another +user's task id reads like an unknown one. + +`principal` receives `{ auth, request }` and may be async. + +### `auth`: the verified token + +`auth` is the MCP SDK's `AuthInfo`: + +```ts +interface AuthInfo { + token: string; + clientId: string; // the OAuth app, e.g. one id for every ChatGPT user + scopes: string[]; + expiresAt?: number; + extra?: Record; // free-form, filled by your verifier +} +``` + +The SDK never fills it in from request headers. Your MCP route does, after verifying the bearer +token with your OAuth provider, by passing it to the handler: + +```ts app/api/mcp/route.ts +import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createServer } from "../../../lib/tasks"; + +const mcp = createMcpHandler(() => createServer()); + +export async function POST(request: Request) { + // Verify the bearer token with Clerk, WorkOS, Auth0 or your own auth server. + const authInfo = await verifyToken(request); + if (!authInfo) return new Response("Unauthorized", { status: 401 }); + return mcp.fetch(request, { authInfo }); +} +``` + +Put the user id in `extra` when you build `authInfo`, and read it back in `principal`: + +```ts +principal: ({ auth }) => auth?.extra?.userId as string | undefined, +``` + +Because the token was already checked, this is the path to prefer. Key on the user, not +`auth.clientId`: the client id identifies the OAuth app, which can be one id shared by every user of +a host like ChatGPT. + +### `request`: the raw HTTP request + +Apps that authenticate with a cookie or a session, rather than building an `AuthInfo`, can read the +request instead: + +```ts +principal: async ({ request }) => (await getSession(request))?.userId, +``` + +The request is unverified. Check the session yourself, and never trust a header like `x-user-id` +that the caller can set. -There is no anonymous mode: when `principal` returns `undefined`, the tool call is refused with "Not -authenticated". A server with no users of its own passes `principal: () => "local"`. +### No anonymous mode -Key on the user, not `auth.clientId`: the client id identifies the OAuth app, which can be one id -shared by every user of a host like ChatGPT. +When `principal` returns `undefined`, the tool call is refused with "Not authenticated". A server +with no users of its own passes `principal: () => "local"`. Task arguments and results are stored in Redis as plain JSON for the task's `ttlMs`, which is @@ -88247,7 +88361,7 @@ import type { WorkflowContext } from "@upstash/workflow"; export const tasks = createTaskLayer({ // 1. the type argument store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, // 3. a TTL longer than the work. It runs from creation and is never extended; once it passes, // the record is gone and isCancelled() returns true. defaults: { ttlMs: 60 * 60 * 1000 }, @@ -88282,7 +88396,7 @@ step is still one invocation, so keep every step within your function limit. | --- | --- | --- | | `store` | required | `RedisTaskStore`, or your own `TaskStore` | | `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | -| `principal` | required | `(auth) => string \| undefined`. The caller's id; `undefined` refuses the call. | +| `principal` | required | `({ auth, request }) => string \| undefined`, may be async. The caller's id; `undefined` refuses the call. | | `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | | `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | | `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | @@ -88306,7 +88420,7 @@ const taskFinished = taskFinishedEvent(events); export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, onSettle: taskFinished.onSettle, }); ``` diff --git a/redis/sdks/agentkit/mcp-events.mdx b/redis/sdks/agentkit/mcp-events.mdx index a2a8ffe54..214da915d 100644 --- a/redis/sdks/agentkit/mcp-events.mdx +++ b/redis/sdks/agentkit/mcp-events.mdx @@ -38,8 +38,9 @@ export const events = createEventLayer({ delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }), // Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 secretKey: process.env.MCP_EVENTS_SECRET_KEY, - // Receives the AuthInfo your auth middleware attached to the request. - principal: (auth) => auth?.extra?.userId as string | undefined, + // Who is calling. `auth` is the verified token your MCP route passes to the SDK; see + // "Users" in the MCP Tasks docs for how it's populated. + principal: ({ auth }) => auth?.extra?.userId as string | undefined, }); export const commentCreated = events.define("comment.created", { @@ -165,14 +166,16 @@ reaches the right user's agent, so your server never needs to know who the host subscribing to the same event produce two subscriptions and two deliveries. **Your server decides who receives what.** `events/subscribe` arrives with the same auth as any -other MCP request. `principal(auth)` turns it into an owner id, which is stored on the +other MCP request: the `AuthInfo` your route verified and passed to `handler.fetch(request, { +authInfo })` (see [Users](/redis/sdks/agentkit/mcp-tasks#users)). `principal({ auth, request })` +turns it into an owner id, which is stored on the subscription and becomes part of its id: ```ts const events = createEventLayer({ store, delivery, - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, }); const deployFinished = events.define("deploy.finished", { @@ -226,7 +229,7 @@ payload includes the result when it fits in the 256 KiB envelope. detail goes to your server logs. - **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret skips the challenge; a new secret verifies again. -- **Authorization:** `authorize` runs on every subscribe and refresh. +- **Authorization:** `authorize(args, { principal, auth, request })` runs on every subscribe and refresh. - **Delivery endpoint:** verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` variables (or a `receiver` you pass). Without them it throws on the first request; it never sends unverified deliveries. @@ -253,7 +256,7 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | --- | | `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | | `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `principal` | required | `(auth) => string \| undefined`. Owns each subscription; `undefined` refuses. | +| `principal` | required | `({ auth, request }) => string \| undefined`, may be async. Owns each subscription; `undefined` refuses. | | `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | | `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | | `defaults.maxTtlMs` | 30 days | The most the server grants | diff --git a/redis/sdks/agentkit/mcp-tasks.mdx b/redis/sdks/agentkit/mcp-tasks.mdx index f6eab0b72..b4df9f652 100644 --- a/redis/sdks/agentkit/mcp-tasks.mdx +++ b/redis/sdks/agentkit/mcp-tasks.mdx @@ -36,7 +36,7 @@ import * as z from "zod"; export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, }); // Defined at module scope, so every instance knows the handler, including @@ -65,7 +65,7 @@ export function createServer() { ``` `topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. `principal` is -required; see [Users](#users). +required, and `auth` is the verified token your route passes in; see [Users](#users). ### 2. Serve the MCP endpoint @@ -169,15 +169,69 @@ async ({ sources }, task) => { ## Users -`principal` is required. It receives the `AuthInfo` your auth middleware attached to the request and -returns the caller's id. Each task records who started it, and `task_status` / `task_cancel` only -answer for that caller. Another user's task id reads like an unknown one. +`principal` is required. It maps each call to a stable caller id, usually your user id. Each task +records who started it, and `task_status` / `task_cancel` only answer for that caller. Another +user's task id reads like an unknown one. -There is no anonymous mode: when `principal` returns `undefined`, the tool call is refused with "Not -authenticated". A server with no users of its own passes `principal: () => "local"`. +`principal` receives `{ auth, request }` and may be async. -Key on the user, not `auth.clientId`: the client id identifies the OAuth app, which can be one id -shared by every user of a host like ChatGPT. +### `auth`: the verified token + +`auth` is the MCP SDK's `AuthInfo`: + +```ts +interface AuthInfo { + token: string; + clientId: string; // the OAuth app, e.g. one id for every ChatGPT user + scopes: string[]; + expiresAt?: number; + extra?: Record; // free-form, filled by your verifier +} +``` + +The SDK never fills it in from request headers. Your MCP route does, after verifying the bearer +token with your OAuth provider, by passing it to the handler: + +```ts app/api/mcp/route.ts +import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createServer } from "../../../lib/tasks"; + +const mcp = createMcpHandler(() => createServer()); + +export async function POST(request: Request) { + // Verify the bearer token with Clerk, WorkOS, Auth0 or your own auth server. + const authInfo = await verifyToken(request); + if (!authInfo) return new Response("Unauthorized", { status: 401 }); + return mcp.fetch(request, { authInfo }); +} +``` + +Put the user id in `extra` when you build `authInfo`, and read it back in `principal`: + +```ts +principal: ({ auth }) => auth?.extra?.userId as string | undefined, +``` + +Because the token was already checked, this is the path to prefer. Key on the user, not +`auth.clientId`: the client id identifies the OAuth app, which can be one id shared by every user of +a host like ChatGPT. + +### `request`: the raw HTTP request + +Apps that authenticate with a cookie or a session, rather than building an `AuthInfo`, can read the +request instead: + +```ts +principal: async ({ request }) => (await getSession(request))?.userId, +``` + +The request is unverified. Check the session yourself, and never trust a header like `x-user-id` +that the caller can set. + +### No anonymous mode + +When `principal` returns `undefined`, the tool call is refused with "Not authenticated". A server +with no users of its own passes `principal: () => "local"`. Task arguments and results are stored in Redis as plain JSON for the task's `ttlMs`, which is @@ -218,7 +272,7 @@ import type { WorkflowContext } from "@upstash/workflow"; export const tasks = createTaskLayer({ // 1. the type argument store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, // 3. a TTL longer than the work. It runs from creation and is never extended; once it passes, // the record is gone and isCancelled() returns true. defaults: { ttlMs: 60 * 60 * 1000 }, @@ -253,7 +307,7 @@ step is still one invocation, so keep every step within your function limit. | --- | --- | --- | | `store` | required | `RedisTaskStore`, or your own `TaskStore` | | `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | -| `principal` | required | `(auth) => string \| undefined`. The caller's id; `undefined` refuses the call. | +| `principal` | required | `({ auth, request }) => string \| undefined`, may be async. The caller's id; `undefined` refuses the call. | | `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | | `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | | `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | @@ -277,7 +331,7 @@ const taskFinished = taskFinishedEvent(events); export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: (auth) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => auth?.extra?.userId as string | undefined, onSettle: taskFinished.onSettle, }); ``` From ad080d7559b77d52567951730bfcc6bd4fb64726 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 12:19:29 +0000 Subject: [PATCH 06/16] DX-3022: docs: authorize at subscribe and delivery, optional `to`, typed args - emit needs no recipients; authorize is required and re-checked before each delivery - principal may return { id, context } for the delivery-time check; the token is never stored - personal events require `to`; args is required when the payload lacks an input field Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK --- llms-full.txt | 244 +++++++++++++++++++---------- redis/sdks/agentkit/mcp-events.mdx | 122 ++++++++++----- 2 files changed, 246 insertions(+), 120 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 243b57a68..902b2b3a9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -87228,7 +87228,8 @@ export const commentCreated = events.define("comment.created", { input: z.object({ documentId: z.string() }), // What every delivery carries. payload: z.object({ documentId: z.string(), commentId: z.string(), text: z.string() }), - // Optional: who may subscribe with these arguments. + // Required: who may subscribe with these arguments and receive what they match. + // Runs on every subscribe and refresh, and again before every delivery. authorize: (args, { principal }) => canRead(principal, args.documentId), }); ``` @@ -87265,17 +87266,14 @@ failed so QStash retries. A bad signature or body gets `489` with `Upstash-NonRe ### Emit Call `emit` wherever the change happens: a route, a webhook from your own app, a background job. -Every `emit` names who may receive it, `owner` for one user or `owners` for several: ```ts -await commentCreated.emit( - { documentId: "doc_123", commentId: "c_9", text: "Ship it?" }, - { owners: await membersOf("doc_123") }, -); +await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); ``` -`emit` is typed by the `payload` schema and validates against it. It reaches only the named users' -matching subscriptions, and returns the event id and how many subscriptions matched. +`emit` is typed by the `payload` schema and validates against it. It reaches every subscription +whose arguments match (everyone watching `doc_123`), checks `authorize` again for each subscriber +before delivering, and returns the event id and how many subscriptions matched. ### Environment variables @@ -87297,7 +87295,7 @@ APP_URL=https://your-app.vercel.app `whsec_` signing secret. 2. For a new callback or secret, your server POSTs a signed verification challenge and requires it echoed back, then stores the subscription and answers with its `id` and a `refreshBefore` time. -3. When you `emit`, every matching subscription of the named owners gets a POST with the event envelope and +3. When you `emit`, every matching subscription that `authorize` still allows gets a POST with the event envelope and [Standard Webhooks](https://www.standardwebhooks.com/) headers: ```json @@ -87319,13 +87317,27 @@ A subscription matches when every argument it gave equals the emitted value. Emi `{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of `{ repo: "a", branch: "main" }`, and of `{}`. -By default the values come from the payload fields named in the input schema. Pass options to -control it: +The values come from the payload fields named in the input schema, so when the payload carries +every input field, `emit(payload)` needs nothing else. When it doesn't, the type of `emit` makes +`args` required, and an emit with no value for a required input field throws, so a filter can never +silently match nothing: + +```ts +const replyPosted = events.define("reply.posted", { + description: "A reply was posted in a thread.", + input: z.object({ threadId: z.string() }), + payload: z.object({ text: z.string() }), // no threadId here + authorize: (args, { principal }) => canReadThread(principal, args.threadId), +}); + +await replyPosted.emit({ text: "Agreed" }, { args: { threadId } }); // `args` is required +``` + +Other options: ```ts await commentCreated.emit(payload, { - owner: userId, // or owners: [...]; required - args: { documentId }, // match on these values instead + to: ["alice", "carol"], // only these users' subscriptions; optional eventId: comment.id, // emitting the same id twice is deduplicated }); ``` @@ -87335,8 +87347,8 @@ event definition. ## Users and subscriptions -Two things decide who gets an event: the callback URL says **where** it goes, and the principal -says **who** may receive it. +Three things decide who gets an event: the callback URL says **where** it goes, the subscription's +arguments say **what**, and `authorize` says **who may**. **The host routes to its user.** Every subscription carries a callback URL and signing secret that the host generated for it. ChatGPT, for example, sends a unique @@ -87344,35 +87356,64 @@ the host generated for it. ChatGPT, for example, sends a unique reaches the right user's agent, so your server never needs to know who the host user is. Two users subscribing to the same event produce two subscriptions and two deliveries. -**Your server decides who receives what.** `events/subscribe` arrives with the same auth as any -other MCP request: the `AuthInfo` your route verified and passed to `handler.fetch(request, { -authInfo })` (see [Users](/docs/redis/sdks/agentkit/mcp-tasks#users)). `principal({ auth, request })` -turns it into an owner id, which is stored on the -subscription and becomes part of its id: +**Your server knows who subscribed.** `events/subscribe` arrives with the same auth as any other MCP +request: the `AuthInfo` your route verified and passed to `handler.fetch(request, { authInfo })` +(see [Users](/docs/redis/sdks/agentkit/mcp-tasks#users)). `principal({ auth, request })` turns it into +the subscriber's id, which is stored on the subscription and becomes part of its id. + +**`authorize` decides who may, and keeps deciding.** It is required, and runs twice: + +| When | What it gets | +| --- | --- | +| Every subscribe and refresh | `args`, `{ principal, context, phase: "subscribe", auth, request }` | +| Before every delivery | `args`, `{ principal, context, phase: "deliver" }` | + +The token is never stored, so there is no `auth` or `request` behind a delivery. The second check is +what stops events when someone loses access after subscribing; a delivery it refuses is dropped, not +retried. An event any authenticated caller may hear says so with `authorize: () => true`. + +**Context for the delivery check.** Return `{ id, context }` from `principal` instead of a string +to store small, non-secret data (an org id, a role) with the subscription. `authorize` gets it back +as `context`: ```ts const events = createEventLayer({ store, delivery, - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => ({ + id: auth?.extra?.userId as string, + context: { orgId: auth?.extra?.orgId }, // never the token + }), +}); + +const memoPosted = events.define("memo.posted", { + description: "A memo was posted to your org.", + input: z.object({ orgId: z.string() }), + payload: z.object({ orgId: z.string(), text: z.string() }), + authorize: (args, { context }) => context.orgId === args.orgId, }); +``` + +`context` is a snapshot from subscribe time, capped at 4 KB. For revocation, check your own data +with `principal` rather than trusting the snapshot. + +**Personal events use `to`.** Some events belong to one user, like "your export finished". Mark them +`personal: true`, and every `emit` must name the user with `to`; the type requires it, and so does +a runtime check. A subscription with no arguments then only hears about its own user's events: -const deployFinished = events.define("deploy.finished", { - description: "A deploy finished.", - input: z.object({ service: z.string().optional() }), - payload: Deploy, - // Runs on every subscribe and refresh. - authorize: (args, { principal }) => canSeeService(principal, args.service), +```ts +const exportFinished = events.define("export.finished", { + description: "Your export finished.", + payload: z.object({ exportId: z.string(), url: z.string() }), + personal: true, + authorize: () => true, // `to` already limits it to the export's owner }); + +await exportFinished.emit({ exportId, url }, { to: userId }); ``` -* Every `emit` names its recipients. `emit(payload, { owner: userId })` reaches one user's - subscriptions, for personal events like "your export finished". `emit(payload, { owners })` - reaches several, for shared ones like a deploy everyone on the team may see. -* So a subscription with no arguments still only hears about its own user's events. `authorize` - adds a per-argument check on top. -* Unsubscribing recomputes the id from the caller's principal, so only the owner can remove a - subscription. +Unsubscribing recomputes the id from the caller's principal, so only the subscriber can remove a +subscription. `principal` is required, and there are no anonymous subscriptions: when it returns `undefined`, @@ -87393,8 +87434,8 @@ const taskFinished = taskFinishedEvent(events); export const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); ``` -It fires once when a task completes, fails or is cancelled, and only to subscriptions created by -the task's owner. We haven't tested it with ChatGPT yet. A host subscribes with no arguments for every task, or with `taskId` for one. The +It fires once when a task completes, fails or is cancelled. It is a personal event, emitted `to` the +task's owner, so only their subscriptions get it. We haven't tested it with ChatGPT yet. A host subscribes with no arguments for every task, or with `taskId` for one. The payload includes the result when it fits in the 256 KiB envelope. ## Security @@ -87408,7 +87449,8 @@ payload includes the result when it fits in the 256 KiB envelope. detail goes to your server logs. * **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret skips the challenge; a new secret verifies again. -* **Authorization:** `authorize(args, { principal, auth, request })` runs on every subscribe and refresh. +* **Authorization:** `authorize` runs on every subscribe and refresh, and again before every + delivery. * **Delivery endpoint:** verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` variables (or a `receiver` you pass). Without them it throws on the first request; it never sends unverified deliveries. @@ -87435,15 +87477,15 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | --- | | `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | | `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `principal` | required | `({ auth, request }) => string \| undefined`, may be async. Owns each subscription; `undefined` refuses. | +| `principal` | required | `({ auth, request }) => id \| { id, context } \| undefined`, may be async. The subscriber; `undefined` refuses. | | `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | | `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | | `defaults.maxTtlMs` | 30 days | The most the server grants | | `allowInsecureCallbacks` | `false` | Local development only | | `timeoutMs` | 10 seconds | Timeout for each POST to a callback | -**`define` config:** `description` and `payload`, plus optional `title`, `input`, `authorize` and -`match`. +**`define` config:** `description`, `payload` and `authorize` (required), plus optional `title`, +`input`, `personal` and `match`. **`QStashDelivery`:** `url` (required), `retries` (default 3), `retryDelay`, `headers`, `qstash`, `receiver`. @@ -87508,7 +87550,8 @@ export const commentCreated = events.define("comment.created", { input: z.object({ documentId: z.string() }), // What every delivery carries. payload: z.object({ documentId: z.string(), commentId: z.string(), text: z.string() }), - // Optional: who may subscribe with these arguments. + // Required: who may subscribe with these arguments and receive what they match. + // Runs on every subscribe and refresh, and again before every delivery. authorize: (args, { principal }) => canRead(principal, args.documentId), }); ``` @@ -87545,17 +87588,14 @@ failed so QStash retries. A bad signature or body gets `489` with `Upstash-NonRe ### Emit Call `emit` wherever the change happens: a route, a webhook from your own app, a background job. -Every `emit` names who may receive it, `owner` for one user or `owners` for several: ```ts -await commentCreated.emit( - { documentId: "doc_123", commentId: "c_9", text: "Ship it?" }, - { owners: await membersOf("doc_123") }, -); +await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); ``` -`emit` is typed by the `payload` schema and validates against it. It reaches only the named users' -matching subscriptions, and returns the event id and how many subscriptions matched. +`emit` is typed by the `payload` schema and validates against it. It reaches every subscription +whose arguments match (everyone watching `doc_123`), checks `authorize` again for each subscriber +before delivering, and returns the event id and how many subscriptions matched. ### Environment variables @@ -87577,7 +87617,7 @@ APP_URL=https://your-app.vercel.app `whsec_` signing secret. 2. For a new callback or secret, your server POSTs a signed verification challenge and requires it echoed back, then stores the subscription and answers with its `id` and a `refreshBefore` time. -3. When you `emit`, every matching subscription of the named owners gets a POST with the event envelope and +3. When you `emit`, every matching subscription that `authorize` still allows gets a POST with the event envelope and [Standard Webhooks](https://www.standardwebhooks.com/) headers: ```json @@ -87599,13 +87639,27 @@ A subscription matches when every argument it gave equals the emitted value. Emi `{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of `{ repo: "a", branch: "main" }`, and of `{}`. -By default the values come from the payload fields named in the input schema. Pass options to -control it: +The values come from the payload fields named in the input schema, so when the payload carries +every input field, `emit(payload)` needs nothing else. When it doesn't, the type of `emit` makes +`args` required, and an emit with no value for a required input field throws, so a filter can never +silently match nothing: + +```ts +const replyPosted = events.define("reply.posted", { + description: "A reply was posted in a thread.", + input: z.object({ threadId: z.string() }), + payload: z.object({ text: z.string() }), // no threadId here + authorize: (args, { principal }) => canReadThread(principal, args.threadId), +}); + +await replyPosted.emit({ text: "Agreed" }, { args: { threadId } }); // `args` is required +``` + +Other options: ```ts await commentCreated.emit(payload, { - owner: userId, // or owners: [...]; required - args: { documentId }, // match on these values instead + to: ["alice", "carol"], // only these users' subscriptions; optional eventId: comment.id, // emitting the same id twice is deduplicated }); ``` @@ -87615,8 +87669,8 @@ event definition. ## Users and subscriptions -Two things decide who gets an event: the callback URL says **where** it goes, and the principal -says **who** may receive it. +Three things decide who gets an event: the callback URL says **where** it goes, the subscription's +arguments say **what**, and `authorize` says **who may**. **The host routes to its user.** Every subscription carries a callback URL and signing secret that the host generated for it. ChatGPT, for example, sends a unique @@ -87624,35 +87678,64 @@ the host generated for it. ChatGPT, for example, sends a unique reaches the right user's agent, so your server never needs to know who the host user is. Two users subscribing to the same event produce two subscriptions and two deliveries. -**Your server decides who receives what.** `events/subscribe` arrives with the same auth as any -other MCP request: the `AuthInfo` your route verified and passed to `handler.fetch(request, { -authInfo })` (see [Users](/docs/redis/sdks/agentkit/mcp-tasks#users)). `principal({ auth, request })` -turns it into an owner id, which is stored on the -subscription and becomes part of its id: +**Your server knows who subscribed.** `events/subscribe` arrives with the same auth as any other MCP +request: the `AuthInfo` your route verified and passed to `handler.fetch(request, { authInfo })` +(see [Users](/docs/redis/sdks/agentkit/mcp-tasks#users)). `principal({ auth, request })` turns it into +the subscriber's id, which is stored on the subscription and becomes part of its id. + +**`authorize` decides who may, and keeps deciding.** It is required, and runs twice: + +| When | What it gets | +| --- | --- | +| Every subscribe and refresh | `args`, `{ principal, context, phase: "subscribe", auth, request }` | +| Before every delivery | `args`, `{ principal, context, phase: "deliver" }` | + +The token is never stored, so there is no `auth` or `request` behind a delivery. The second check is +what stops events when someone loses access after subscribing; a delivery it refuses is dropped, not +retried. An event any authenticated caller may hear says so with `authorize: () => true`. + +**Context for the delivery check.** Return `{ id, context }` from `principal` instead of a string +to store small, non-secret data (an org id, a role) with the subscription. `authorize` gets it back +as `context`: ```ts const events = createEventLayer({ store, delivery, - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => ({ + id: auth?.extra?.userId as string, + context: { orgId: auth?.extra?.orgId }, // never the token + }), +}); + +const memoPosted = events.define("memo.posted", { + description: "A memo was posted to your org.", + input: z.object({ orgId: z.string() }), + payload: z.object({ orgId: z.string(), text: z.string() }), + authorize: (args, { context }) => context.orgId === args.orgId, }); +``` + +`context` is a snapshot from subscribe time, capped at 4 KB. For revocation, check your own data +with `principal` rather than trusting the snapshot. + +**Personal events use `to`.** Some events belong to one user, like "your export finished". Mark them +`personal: true`, and every `emit` must name the user with `to`; the type requires it, and so does +a runtime check. A subscription with no arguments then only hears about its own user's events: -const deployFinished = events.define("deploy.finished", { - description: "A deploy finished.", - input: z.object({ service: z.string().optional() }), - payload: Deploy, - // Runs on every subscribe and refresh. - authorize: (args, { principal }) => canSeeService(principal, args.service), +```ts +const exportFinished = events.define("export.finished", { + description: "Your export finished.", + payload: z.object({ exportId: z.string(), url: z.string() }), + personal: true, + authorize: () => true, // `to` already limits it to the export's owner }); + +await exportFinished.emit({ exportId, url }, { to: userId }); ``` -* Every `emit` names its recipients. `emit(payload, { owner: userId })` reaches one user's - subscriptions, for personal events like "your export finished". `emit(payload, { owners })` - reaches several, for shared ones like a deploy everyone on the team may see. -* So a subscription with no arguments still only hears about its own user's events. `authorize` - adds a per-argument check on top. -* Unsubscribing recomputes the id from the caller's principal, so only the owner can remove a - subscription. +Unsubscribing recomputes the id from the caller's principal, so only the subscriber can remove a +subscription. `principal` is required, and there are no anonymous subscriptions: when it returns `undefined`, @@ -87673,8 +87756,8 @@ const taskFinished = taskFinishedEvent(events); export const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); ``` -It fires once when a task completes, fails or is cancelled, and only to subscriptions created by -the task's owner. We haven't tested it with ChatGPT yet. A host subscribes with no arguments for every task, or with `taskId` for one. The +It fires once when a task completes, fails or is cancelled. It is a personal event, emitted `to` the +task's owner, so only their subscriptions get it. We haven't tested it with ChatGPT yet. A host subscribes with no arguments for every task, or with `taskId` for one. The payload includes the result when it fits in the 256 KiB envelope. ## Security @@ -87688,7 +87771,8 @@ payload includes the result when it fits in the 256 KiB envelope. detail goes to your server logs. * **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret skips the challenge; a new secret verifies again. -* **Authorization:** `authorize(args, { principal, auth, request })` runs on every subscribe and refresh. +* **Authorization:** `authorize` runs on every subscribe and refresh, and again before every + delivery. * **Delivery endpoint:** verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` variables (or a `receiver` you pass). Without them it throws on the first request; it never sends unverified deliveries. @@ -87715,15 +87799,15 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | --- | | `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | | `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `principal` | required | `({ auth, request }) => string \| undefined`, may be async. Owns each subscription; `undefined` refuses. | +| `principal` | required | `({ auth, request }) => id \| { id, context } \| undefined`, may be async. The subscriber; `undefined` refuses. | | `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | | `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | | `defaults.maxTtlMs` | 30 days | The most the server grants | | `allowInsecureCallbacks` | `false` | Local development only | | `timeoutMs` | 10 seconds | Timeout for each POST to a callback | -**`define` config:** `description` and `payload`, plus optional `title`, `input`, `authorize` and -`match`. +**`define` config:** `description`, `payload` and `authorize` (required), plus optional `title`, +`input`, `personal` and `match`. **`QStashDelivery`:** `url` (required), `retries` (default 3), `retryDelay`, `headers`, `qstash`, `receiver`. diff --git a/redis/sdks/agentkit/mcp-events.mdx b/redis/sdks/agentkit/mcp-events.mdx index 214da915d..503b87a3c 100644 --- a/redis/sdks/agentkit/mcp-events.mdx +++ b/redis/sdks/agentkit/mcp-events.mdx @@ -49,7 +49,8 @@ export const commentCreated = events.define("comment.created", { input: z.object({ documentId: z.string() }), // What every delivery carries. payload: z.object({ documentId: z.string(), commentId: z.string(), text: z.string() }), - // Optional: who may subscribe with these arguments. + // Required: who may subscribe with these arguments and receive what they match. + // Runs on every subscribe and refresh, and again before every delivery. authorize: (args, { principal }) => canRead(principal, args.documentId), }); ``` @@ -86,17 +87,14 @@ failed so QStash retries. A bad signature or body gets `489` with `Upstash-NonRe ### Emit Call `emit` wherever the change happens: a route, a webhook from your own app, a background job. -Every `emit` names who may receive it, `owner` for one user or `owners` for several: ```ts -await commentCreated.emit( - { documentId: "doc_123", commentId: "c_9", text: "Ship it?" }, - { owners: await membersOf("doc_123") }, -); +await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); ``` -`emit` is typed by the `payload` schema and validates against it. It reaches only the named users' -matching subscriptions, and returns the event id and how many subscriptions matched. +`emit` is typed by the `payload` schema and validates against it. It reaches every subscription +whose arguments match (everyone watching `doc_123`), checks `authorize` again for each subscriber +before delivering, and returns the event id and how many subscriptions matched. ### Environment variables @@ -118,7 +116,7 @@ APP_URL=https://your-app.vercel.app `whsec_` signing secret. 2. For a new callback or secret, your server POSTs a signed verification challenge and requires it echoed back, then stores the subscription and answers with its `id` and a `refreshBefore` time. -3. When you `emit`, every matching subscription of the named owners gets a POST with the event envelope and +3. When you `emit`, every matching subscription that `authorize` still allows gets a POST with the event envelope and [Standard Webhooks](https://www.standardwebhooks.com/) headers: ```json @@ -140,13 +138,27 @@ A subscription matches when every argument it gave equals the emitted value. Emi `{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of `{ repo: "a", branch: "main" }`, and of `{}`. -By default the values come from the payload fields named in the input schema. Pass options to -control it: +The values come from the payload fields named in the input schema, so when the payload carries +every input field, `emit(payload)` needs nothing else. When it doesn't, the type of `emit` makes +`args` required, and an emit with no value for a required input field throws, so a filter can never +silently match nothing: + +```ts +const replyPosted = events.define("reply.posted", { + description: "A reply was posted in a thread.", + input: z.object({ threadId: z.string() }), + payload: z.object({ text: z.string() }), // no threadId here + authorize: (args, { principal }) => canReadThread(principal, args.threadId), +}); + +await replyPosted.emit({ text: "Agreed" }, { args: { threadId } }); // `args` is required +``` + +Other options: ```ts await commentCreated.emit(payload, { - owner: userId, // or owners: [...]; required - args: { documentId }, // match on these values instead + to: ["alice", "carol"], // only these users' subscriptions; optional eventId: comment.id, // emitting the same id twice is deduplicated }); ``` @@ -156,8 +168,8 @@ event definition. ## Users and subscriptions -Two things decide who gets an event: the callback URL says **where** it goes, and the principal -says **who** may receive it. +Three things decide who gets an event: the callback URL says **where** it goes, the subscription's +arguments say **what**, and `authorize` says **who may**. **The host routes to its user.** Every subscription carries a callback URL and signing secret that the host generated for it. ChatGPT, for example, sends a unique @@ -165,35 +177,64 @@ the host generated for it. ChatGPT, for example, sends a unique reaches the right user's agent, so your server never needs to know who the host user is. Two users subscribing to the same event produce two subscriptions and two deliveries. -**Your server decides who receives what.** `events/subscribe` arrives with the same auth as any -other MCP request: the `AuthInfo` your route verified and passed to `handler.fetch(request, { -authInfo })` (see [Users](/redis/sdks/agentkit/mcp-tasks#users)). `principal({ auth, request })` -turns it into an owner id, which is stored on the -subscription and becomes part of its id: +**Your server knows who subscribed.** `events/subscribe` arrives with the same auth as any other MCP +request: the `AuthInfo` your route verified and passed to `handler.fetch(request, { authInfo })` +(see [Users](/redis/sdks/agentkit/mcp-tasks#users)). `principal({ auth, request })` turns it into +the subscriber's id, which is stored on the subscription and becomes part of its id. + +**`authorize` decides who may, and keeps deciding.** It is required, and runs twice: + +| When | What it gets | +| --- | --- | +| Every subscribe and refresh | `args`, `{ principal, context, phase: "subscribe", auth, request }` | +| Before every delivery | `args`, `{ principal, context, phase: "deliver" }` | + +The token is never stored, so there is no `auth` or `request` behind a delivery. The second check is +what stops events when someone loses access after subscribing; a delivery it refuses is dropped, not +retried. An event any authenticated caller may hear says so with `authorize: () => true`. + +**Context for the delivery check.** Return `{ id, context }` from `principal` instead of a string +to store small, non-secret data (an org id, a role) with the subscription. `authorize` gets it back +as `context`: ```ts const events = createEventLayer({ store, delivery, - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => ({ + id: auth?.extra?.userId as string, + context: { orgId: auth?.extra?.orgId }, // never the token + }), }); -const deployFinished = events.define("deploy.finished", { - description: "A deploy finished.", - input: z.object({ service: z.string().optional() }), - payload: Deploy, - // Runs on every subscribe and refresh. - authorize: (args, { principal }) => canSeeService(principal, args.service), +const memoPosted = events.define("memo.posted", { + description: "A memo was posted to your org.", + input: z.object({ orgId: z.string() }), + payload: z.object({ orgId: z.string(), text: z.string() }), + authorize: (args, { context }) => context.orgId === args.orgId, }); ``` -- Every `emit` names its recipients. `emit(payload, { owner: userId })` reaches one user's - subscriptions, for personal events like "your export finished". `emit(payload, { owners })` - reaches several, for shared ones like a deploy everyone on the team may see. -- So a subscription with no arguments still only hears about its own user's events. `authorize` - adds a per-argument check on top. -- Unsubscribing recomputes the id from the caller's principal, so only the owner can remove a - subscription. +`context` is a snapshot from subscribe time, capped at 4 KB. For revocation, check your own data +with `principal` rather than trusting the snapshot. + +**Personal events use `to`.** Some events belong to one user, like "your export finished". Mark them +`personal: true`, and every `emit` must name the user with `to`; the type requires it, and so does +a runtime check. A subscription with no arguments then only hears about its own user's events: + +```ts +const exportFinished = events.define("export.finished", { + description: "Your export finished.", + payload: z.object({ exportId: z.string(), url: z.string() }), + personal: true, + authorize: () => true, // `to` already limits it to the export's owner +}); + +await exportFinished.emit({ exportId, url }, { to: userId }); +``` + +Unsubscribing recomputes the id from the caller's principal, so only the subscriber can remove a +subscription. `principal` is required, and there are no anonymous subscriptions: when it returns `undefined`, @@ -214,8 +255,8 @@ const taskFinished = taskFinishedEvent(events); export const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); ``` -It fires once when a task completes, fails or is cancelled, and only to subscriptions created by -the task's owner. We haven't tested it with ChatGPT yet. A host subscribes with no arguments for every task, or with `taskId` for one. The +It fires once when a task completes, fails or is cancelled. It is a personal event, emitted `to` the +task's owner, so only their subscriptions get it. We haven't tested it with ChatGPT yet. A host subscribes with no arguments for every task, or with `taskId` for one. The payload includes the result when it fits in the 256 KiB envelope. ## Security @@ -229,7 +270,8 @@ payload includes the result when it fits in the 256 KiB envelope. detail goes to your server logs. - **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret skips the challenge; a new secret verifies again. -- **Authorization:** `authorize(args, { principal, auth, request })` runs on every subscribe and refresh. +- **Authorization:** `authorize` runs on every subscribe and refresh, and again before every + delivery. - **Delivery endpoint:** verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` variables (or a `receiver` you pass). Without them it throws on the first request; it never sends unverified deliveries. @@ -256,15 +298,15 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | --- | | `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | | `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `principal` | required | `({ auth, request }) => string \| undefined`, may be async. Owns each subscription; `undefined` refuses. | +| `principal` | required | `({ auth, request }) => id \| { id, context } \| undefined`, may be async. The subscriber; `undefined` refuses. | | `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | | `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | | `defaults.maxTtlMs` | 30 days | The most the server grants | | `allowInsecureCallbacks` | `false` | Local development only | | `timeoutMs` | 10 seconds | Timeout for each POST to a callback | -**`define` config:** `description` and `payload`, plus optional `title`, `input`, `authorize` and -`match`. +**`define` config:** `description`, `payload` and `authorize` (required), plus optional `title`, +`input`, `personal` and `match`. **`QStashDelivery`:** `url` (required), `retries` (default 3), `retryDelay`, `headers`, `qstash`, `receiver`. From 87222dd52c54565fef1b46f20db9ee63a9ea8bb7 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 8 Oct 2026 13:06:23 +0000 Subject: [PATCH 07/16] DX-3022: docs: /upstash imports, principal throws to refuse, drop idempotencyKey - Upstash backends import from @upstash/mcp-toolkit/upstash; one npm install - principal returns an id and throws when it can't; no `as string | undefined` - idempotencyKey section removed - Workflow: no type argument (inferred), a complete example task Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A5x4btgiRE3FKdpUW527Wu --- llms-full.txt | 244 ++++++++++++++++------------- redis/sdks/agentkit/mcp-events.mdx | 24 +-- redis/sdks/agentkit/mcp-tasks.mdx | 98 ++++++------ 3 files changed, 201 insertions(+), 165 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 902b2b3a9..dfc5b7720 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -87199,7 +87199,7 @@ signing and retries: subscriptions live in Upstash Redis, and every delivery goe ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod ``` ## Quickstart @@ -87209,7 +87209,8 @@ Three files in a Next.js app. ### 1. Define the event layer and your events ```ts lib/events.ts -import { createEventLayer, QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; +import { createEventLayer } from "@upstash/mcp-toolkit/events"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; export const events = createEventLayer({ @@ -87219,7 +87220,11 @@ export const events = createEventLayer({ secretKey: process.env.MCP_EVENTS_SECRET_KEY, // Who is calling. `auth` is the verified token your MCP route passes to the SDK; see // "Users" in the MCP Tasks docs for how it's populated. - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; + }, }); export const commentCreated = events.define("comment.created", { @@ -87380,10 +87385,11 @@ as `context`: const events = createEventLayer({ store, delivery, - principal: ({ auth }) => ({ - id: auth?.extra?.userId as string, - context: { orgId: auth?.extra?.orgId }, // never the token - }), + principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return { id: userId, context: { orgId: auth?.extra?.orgId } }; // never the token + }, }); const memoPosted = events.define("memo.posted", { @@ -87416,7 +87422,7 @@ Unsubscribing recomputes the id from the caller's principal, so only the subscri subscription. - `principal` is required, and there are no anonymous subscriptions: when it returns `undefined`, + `principal` is required, and there are no anonymous subscriptions: when it throws, subscribe and unsubscribe are refused with reason `not_authenticated`, before any challenge is sent. A server with no users of its own passes `principal: () => "local"`. @@ -87477,7 +87483,7 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | --- | | `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | | `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `principal` | required | `({ auth, request }) => id \| { id, context } \| undefined`, may be async. The subscriber; `undefined` refuses. | +| `principal` | required | `({ auth, request }) => id \| { id, context }`, may be async. The subscriber; throw to refuse. | | `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | | `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | | `defaults.maxTtlMs` | 30 days | The most the server grants | @@ -87521,7 +87527,7 @@ signing and retries: subscriptions live in Upstash Redis, and every delivery goe ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod ``` ## Quickstart @@ -87531,7 +87537,8 @@ Three files in a Next.js app. ### 1. Define the event layer and your events ```ts lib/events.ts -import { createEventLayer, QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; +import { createEventLayer } from "@upstash/mcp-toolkit/events"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; export const events = createEventLayer({ @@ -87541,7 +87548,11 @@ export const events = createEventLayer({ secretKey: process.env.MCP_EVENTS_SECRET_KEY, // Who is calling. `auth` is the verified token your MCP route passes to the SDK; see // "Users" in the MCP Tasks docs for how it's populated. - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; + }, }); export const commentCreated = events.define("comment.created", { @@ -87702,10 +87713,11 @@ as `context`: const events = createEventLayer({ store, delivery, - principal: ({ auth }) => ({ - id: auth?.extra?.userId as string, - context: { orgId: auth?.extra?.orgId }, // never the token - }), + principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return { id: userId, context: { orgId: auth?.extra?.orgId } }; // never the token + }, }); const memoPosted = events.define("memo.posted", { @@ -87738,7 +87750,7 @@ Unsubscribing recomputes the id from the caller's principal, so only the subscri subscription. - `principal` is required, and there are no anonymous subscriptions: when it returns `undefined`, + `principal` is required, and there are no anonymous subscriptions: when it throws, subscribe and unsubscribe are refused with reason `not_authenticated`, before any challenge is sent. A server with no users of its own passes `principal: () => "local"`. @@ -87799,7 +87811,7 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | --- | | `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | | `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `principal` | required | `({ auth, request }) => id \| { id, context } \| undefined`, may be async. The subscriber; `undefined` refuses. | +| `principal` | required | `({ auth, request }) => id \| { id, context }`, may be async. The subscriber; throw to refuse. | | `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | | `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | | `defaults.maxTtlMs` | 30 days | The most the server grants | @@ -87840,7 +87852,7 @@ OpenCode and ChatGPT. No client capability is required. The toolkit sits on top TypeScript SDK and uses WebCrypto only, so it runs on Node and edge runtimes. ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod ``` ## Quickstart @@ -87852,14 +87864,18 @@ Three files in a Next.js app. ```ts lib/tasks.ts import { McpServer } from "@modelcontextprotocol/server"; import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; -import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; // Where the task record lives, how the work is delivered, and who is calling. export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; + }, }); // Defined at module scope, so every instance knows the handler, including @@ -88029,10 +88045,15 @@ export async function POST(request: Request) { } ``` -Put the user id in `extra` when you build `authInfo`, and read it back in `principal`: +Put the user id in `extra` when you build `authInfo`, and read it back in `principal`, as in the +quickstart: ```ts -principal: ({ auth }) => auth?.extra?.userId as string | undefined, +principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; +}, ``` Because the token was already checked, this is the path to prefer. Key on the user, not @@ -88045,7 +88066,11 @@ Apps that authenticate with a cookie or a session, rather than building an `Auth request instead: ```ts -principal: async ({ request }) => (await getSession(request))?.userId, +principal: async ({ request }) => { + const session = await getSession(request); + if (!session) throw new Error("Not authenticated"); + return session.userId; +}, ``` The request is unverified. Check the session yourself, and never trust a header like `x-user-id` @@ -88053,7 +88078,9 @@ that the caller can set. ### No anonymous mode -When `principal` returns `undefined`, the tool call is refused with "Not authenticated". A server +`principal` must return an id; its type doesn't allow `undefined`. When it can't identify the +caller, it throws, and the tool call is refused with "Not authenticated". The toolkit fails closed +even if the types are bypassed: an answer without a non-empty string id is refused too. A server with no users of its own passes `principal: () => "local"`. @@ -88061,56 +88088,47 @@ with no users of its own passes `principal: () => "local"`. forever with `ttlMs: null`. Keep secrets out of them, or use a short TTL. -## Retries without duplicates - -Agents retry tool calls, especially ones that seemed to time out. With `idempotencyKey`, a second call -from the same caller with the same key returns the existing task instead of starting another: - -```ts -tasks.define( - "generate_report", - { - description: "Generates a report on a topic.", - inputSchema: z.object({ topic: z.string() }), - idempotencyKey: ({ topic }) => topic, - }, - handler, -); -``` - -Keys are scoped by caller and tool, and last as long as the task is kept (`ttlMs`). - ## Work longer than one function invocation A QStash delivery is one serverless invocation, so the handler has to finish within your platform's function limit. If it is killed, QStash redelivers and the handler starts over. For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, and finished steps -are replayed from a journal instead of re-run. Three things change: +are replayed from a journal instead of re-run. The handler's `task` then also has the Workflow API +(`task.run`, `task.sleep`, `task.call`), inferred from the dispatcher with no extra typing: ```ts lib/tasks.ts -import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; -import type { WorkflowContext } from "@upstash/workflow"; +import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/upstash"; +import * as z from "zod"; -export const tasks = createTaskLayer({ // 1. the type argument +export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => auth?.extra?.userId as string | undefined, - // 3. a TTL longer than the work. It runs from creation and is never extended; once it passes, - // the record is gone and isCancelled() returns true. + principal, // the same function as in the quickstart + // A TTL longer than the work. It runs from creation and is never extended; once it passes, + // the record is gone and isCancelled() returns true. The default is 5 minutes. defaults: { ttlMs: 60 * 60 * 1000 }, }); -``` -2. The work goes inside `task.run(...)` steps. The handler's `task` has the Workflow API, such as -`task.run`, `task.sleep` and `task.call`: - -```ts -async ({ topic }, task) => { - const sources = await task.run("fetch", () => fetchSources(topic)); - const report = await task.run("write", () => writeReport(sources)); - return { content: [{ type: "text", text: report }] }; -}; +tasks.define( + "migrate_workspace", + { + description: "Copies a workspace's documents to the new storage, one batch at a time.", + inputSchema: z.object({ workspaceId: z.string() }), + }, + async ({ workspaceId }, task) => { + const batches = await task.run("plan", () => listBatches(workspaceId)); + for (const [i, batch] of batches.entries()) { + if (await task.isCancelled()) return {}; + await task.update(`Copying batch ${i + 1}/${batches.length}`); + await task.run(`copy-${i}`, () => copyBatch(batch)); + } + await task.sleep("settle", 30); // waits without holding a function open + const report = await task.run("verify", () => verifyCopy(workspaceId)); + return { content: [{ type: "text", text: report }] }; + }, +); ``` Put side effects inside `task.run`. Code outside a step runs again on every invocation, and each @@ -88130,14 +88148,14 @@ step is still one invocation, so keep every step within your function limit. | --- | --- | --- | | `store` | required | `RedisTaskStore`, or your own `TaskStore` | | `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | -| `principal` | required | `({ auth, request }) => string \| undefined`, may be async. The caller's id; `undefined` refuses the call. | +| `principal` | required | `({ auth, request }) => string`, may be async. The caller's id; throw to refuse the call. | | `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | | `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | | `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | | `onSettle` | none | `(task) => void`. Called once when a task completes, fails or is cancelled. | **`tasks.define(name, config, handler)` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, -`pollIntervalMs`, `queuedMessage`, `completedMessage` and `idempotencyKey`. +`pollIntervalMs`, `queuedMessage` and `completedMessage`. ## Push instead of poll @@ -88154,7 +88172,7 @@ const taskFinished = taskFinishedEvent(events); export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal, onSettle: taskFinished.onSettle, }); ``` @@ -88190,7 +88208,7 @@ OpenCode and ChatGPT. No client capability is required. The toolkit sits on top TypeScript SDK and uses WebCrypto only, so it runs on Node and edge runtimes. ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod ``` ## Quickstart @@ -88202,14 +88220,18 @@ Three files in a Next.js app. ```ts lib/tasks.ts import { McpServer } from "@modelcontextprotocol/server"; import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; -import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; // Where the task record lives, how the work is delivered, and who is calling. export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; + }, }); // Defined at module scope, so every instance knows the handler, including @@ -88379,10 +88401,15 @@ export async function POST(request: Request) { } ``` -Put the user id in `extra` when you build `authInfo`, and read it back in `principal`: +Put the user id in `extra` when you build `authInfo`, and read it back in `principal`, as in the +quickstart: ```ts -principal: ({ auth }) => auth?.extra?.userId as string | undefined, +principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; +}, ``` Because the token was already checked, this is the path to prefer. Key on the user, not @@ -88395,7 +88422,11 @@ Apps that authenticate with a cookie or a session, rather than building an `Auth request instead: ```ts -principal: async ({ request }) => (await getSession(request))?.userId, +principal: async ({ request }) => { + const session = await getSession(request); + if (!session) throw new Error("Not authenticated"); + return session.userId; +}, ``` The request is unverified. Check the session yourself, and never trust a header like `x-user-id` @@ -88403,7 +88434,9 @@ that the caller can set. ### No anonymous mode -When `principal` returns `undefined`, the tool call is refused with "Not authenticated". A server +`principal` must return an id; its type doesn't allow `undefined`. When it can't identify the +caller, it throws, and the tool call is refused with "Not authenticated". The toolkit fails closed +even if the types are bypassed: an answer without a non-empty string id is refused too. A server with no users of its own passes `principal: () => "local"`. @@ -88411,56 +88444,47 @@ with no users of its own passes `principal: () => "local"`. forever with `ttlMs: null`. Keep secrets out of them, or use a short TTL. -## Retries without duplicates - -Agents retry tool calls, especially ones that seemed to time out. With `idempotencyKey`, a second call -from the same caller with the same key returns the existing task instead of starting another: - -```ts -tasks.define( - "generate_report", - { - description: "Generates a report on a topic.", - inputSchema: z.object({ topic: z.string() }), - idempotencyKey: ({ topic }) => topic, - }, - handler, -); -``` - -Keys are scoped by caller and tool, and last as long as the task is kept (`ttlMs`). - ## Work longer than one function invocation A QStash delivery is one serverless invocation, so the handler has to finish within your platform's function limit. If it is killed, QStash redelivers and the handler starts over. For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, and finished steps -are replayed from a journal instead of re-run. Three things change: +are replayed from a journal instead of re-run. The handler's `task` then also has the Workflow API +(`task.run`, `task.sleep`, `task.call`), inferred from the dispatcher with no extra typing: ```ts lib/tasks.ts -import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; -import type { WorkflowContext } from "@upstash/workflow"; +import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/upstash"; +import * as z from "zod"; -export const tasks = createTaskLayer({ // 1. the type argument +export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => auth?.extra?.userId as string | undefined, - // 3. a TTL longer than the work. It runs from creation and is never extended; once it passes, - // the record is gone and isCancelled() returns true. + principal, // the same function as in the quickstart + // A TTL longer than the work. It runs from creation and is never extended; once it passes, + // the record is gone and isCancelled() returns true. The default is 5 minutes. defaults: { ttlMs: 60 * 60 * 1000 }, }); -``` - -2. The work goes inside `task.run(...)` steps. The handler's `task` has the Workflow API, such as -`task.run`, `task.sleep` and `task.call`: -```ts -async ({ topic }, task) => { - const sources = await task.run("fetch", () => fetchSources(topic)); - const report = await task.run("write", () => writeReport(sources)); - return { content: [{ type: "text", text: report }] }; -}; +tasks.define( + "migrate_workspace", + { + description: "Copies a workspace's documents to the new storage, one batch at a time.", + inputSchema: z.object({ workspaceId: z.string() }), + }, + async ({ workspaceId }, task) => { + const batches = await task.run("plan", () => listBatches(workspaceId)); + for (const [i, batch] of batches.entries()) { + if (await task.isCancelled()) return {}; + await task.update(`Copying batch ${i + 1}/${batches.length}`); + await task.run(`copy-${i}`, () => copyBatch(batch)); + } + await task.sleep("settle", 30); // waits without holding a function open + const report = await task.run("verify", () => verifyCopy(workspaceId)); + return { content: [{ type: "text", text: report }] }; + }, +); ``` Put side effects inside `task.run`. Code outside a step runs again on every invocation, and each @@ -88480,14 +88504,14 @@ step is still one invocation, so keep every step within your function limit. | --- | --- | --- | | `store` | required | `RedisTaskStore`, or your own `TaskStore` | | `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | -| `principal` | required | `({ auth, request }) => string \| undefined`, may be async. The caller's id; `undefined` refuses the call. | +| `principal` | required | `({ auth, request }) => string`, may be async. The caller's id; throw to refuse the call. | | `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | | `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | | `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | | `onSettle` | none | `(task) => void`. Called once when a task completes, fails or is cancelled. | **`tasks.define(name, config, handler)` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, -`pollIntervalMs`, `queuedMessage`, `completedMessage` and `idempotencyKey`. +`pollIntervalMs`, `queuedMessage` and `completedMessage`. ## Push instead of poll @@ -88504,7 +88528,7 @@ const taskFinished = taskFinishedEvent(events); export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal, onSettle: taskFinished.onSettle, }); ``` diff --git a/redis/sdks/agentkit/mcp-events.mdx b/redis/sdks/agentkit/mcp-events.mdx index 503b87a3c..38f0009e6 100644 --- a/redis/sdks/agentkit/mcp-events.mdx +++ b/redis/sdks/agentkit/mcp-events.mdx @@ -20,7 +20,7 @@ signing and retries: subscriptions live in Upstash Redis, and every delivery goe ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod ``` ## Quickstart @@ -30,7 +30,8 @@ Three files in a Next.js app. ### 1. Define the event layer and your events ```ts lib/events.ts -import { createEventLayer, QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/events"; +import { createEventLayer } from "@upstash/mcp-toolkit/events"; +import { QStashDelivery, RedisSubscriptionStore } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; export const events = createEventLayer({ @@ -40,7 +41,11 @@ export const events = createEventLayer({ secretKey: process.env.MCP_EVENTS_SECRET_KEY, // Who is calling. `auth` is the verified token your MCP route passes to the SDK; see // "Users" in the MCP Tasks docs for how it's populated. - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; + }, }); export const commentCreated = events.define("comment.created", { @@ -201,10 +206,11 @@ as `context`: const events = createEventLayer({ store, delivery, - principal: ({ auth }) => ({ - id: auth?.extra?.userId as string, - context: { orgId: auth?.extra?.orgId }, // never the token - }), + principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return { id: userId, context: { orgId: auth?.extra?.orgId } }; // never the token + }, }); const memoPosted = events.define("memo.posted", { @@ -237,7 +243,7 @@ Unsubscribing recomputes the id from the caller's principal, so only the subscri subscription. - `principal` is required, and there are no anonymous subscriptions: when it returns `undefined`, + `principal` is required, and there are no anonymous subscriptions: when it throws, subscribe and unsubscribe are refused with reason `not_authenticated`, before any challenge is sent. A server with no users of its own passes `principal: () => "local"`. @@ -298,7 +304,7 @@ payload includes the result when it fits in the 256 KiB envelope. | --- | --- | --- | | `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | | `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `principal` | required | `({ auth, request }) => id \| { id, context } \| undefined`, may be async. The subscriber; `undefined` refuses. | +| `principal` | required | `({ auth, request }) => id \| { id, context }`, may be async. The subscriber; throw to refuse. | | `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | | `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | | `defaults.maxTtlMs` | 30 days | The most the server grants | diff --git a/redis/sdks/agentkit/mcp-tasks.mdx b/redis/sdks/agentkit/mcp-tasks.mdx index b4df9f652..c14597edf 100644 --- a/redis/sdks/agentkit/mcp-tasks.mdx +++ b/redis/sdks/agentkit/mcp-tasks.mdx @@ -17,7 +17,7 @@ OpenCode and ChatGPT. No client capability is required. The toolkit sits on top TypeScript SDK and uses WebCrypto only, so it runs on Node and edge runtimes. ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server @upstash/redis @upstash/qstash zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod ``` ## Quickstart @@ -29,14 +29,18 @@ Three files in a Next.js app. ```ts lib/tasks.ts import { McpServer } from "@modelcontextprotocol/server"; import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; -import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/tasks/upstash"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/upstash"; import * as z from "zod"; // Where the task record lives, how the work is delivered, and who is calling. export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; + }, }); // Defined at module scope, so every instance knows the handler, including @@ -206,10 +210,15 @@ export async function POST(request: Request) { } ``` -Put the user id in `extra` when you build `authInfo`, and read it back in `principal`: +Put the user id in `extra` when you build `authInfo`, and read it back in `principal`, as in the +quickstart: ```ts -principal: ({ auth }) => auth?.extra?.userId as string | undefined, +principal: ({ auth }) => { + const userId = auth?.extra?.userId; + if (typeof userId !== "string") throw new Error("Not authenticated"); + return userId; +}, ``` Because the token was already checked, this is the path to prefer. Key on the user, not @@ -222,7 +231,11 @@ Apps that authenticate with a cookie or a session, rather than building an `Auth request instead: ```ts -principal: async ({ request }) => (await getSession(request))?.userId, +principal: async ({ request }) => { + const session = await getSession(request); + if (!session) throw new Error("Not authenticated"); + return session.userId; +}, ``` The request is unverified. Check the session yourself, and never trust a header like `x-user-id` @@ -230,7 +243,9 @@ that the caller can set. ### No anonymous mode -When `principal` returns `undefined`, the tool call is refused with "Not authenticated". A server +`principal` must return an id; its type doesn't allow `undefined`. When it can't identify the +caller, it throws, and the tool call is refused with "Not authenticated". The toolkit fails closed +even if the types are bypassed: an answer without a non-empty string id is refused too. A server with no users of its own passes `principal: () => "local"`. @@ -238,56 +253,47 @@ with no users of its own passes `principal: () => "local"`. forever with `ttlMs: null`. Keep secrets out of them, or use a short TTL. -## Retries without duplicates - -Agents retry tool calls, especially ones that seemed to time out. With `idempotencyKey`, a second call -from the same caller with the same key returns the existing task instead of starting another: - -```ts -tasks.define( - "generate_report", - { - description: "Generates a report on a topic.", - inputSchema: z.object({ topic: z.string() }), - idempotencyKey: ({ topic }) => topic, - }, - handler, -); -``` - -Keys are scoped by caller and tool, and last as long as the task is kept (`ttlMs`). - ## Work longer than one function invocation A QStash delivery is one serverless invocation, so the handler has to finish within your platform's function limit. If it is killed, QStash redelivers and the handler starts over. For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, and finished steps -are replayed from a journal instead of re-run. Three things change: +are replayed from a journal instead of re-run. The handler's `task` then also has the Workflow API +(`task.run`, `task.sleep`, `task.call`), inferred from the dispatcher with no extra typing: ```ts lib/tasks.ts -import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/tasks/upstash"; -import type { WorkflowContext } from "@upstash/workflow"; +import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/upstash"; +import * as z from "zod"; -export const tasks = createTaskLayer({ // 1. the type argument +export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => auth?.extra?.userId as string | undefined, - // 3. a TTL longer than the work. It runs from creation and is never extended; once it passes, - // the record is gone and isCancelled() returns true. + principal, // the same function as in the quickstart + // A TTL longer than the work. It runs from creation and is never extended; once it passes, + // the record is gone and isCancelled() returns true. The default is 5 minutes. defaults: { ttlMs: 60 * 60 * 1000 }, }); -``` -2. The work goes inside `task.run(...)` steps. The handler's `task` has the Workflow API, such as -`task.run`, `task.sleep` and `task.call`: - -```ts -async ({ topic }, task) => { - const sources = await task.run("fetch", () => fetchSources(topic)); - const report = await task.run("write", () => writeReport(sources)); - return { content: [{ type: "text", text: report }] }; -}; +tasks.define( + "migrate_workspace", + { + description: "Copies a workspace's documents to the new storage, one batch at a time.", + inputSchema: z.object({ workspaceId: z.string() }), + }, + async ({ workspaceId }, task) => { + const batches = await task.run("plan", () => listBatches(workspaceId)); + for (const [i, batch] of batches.entries()) { + if (await task.isCancelled()) return {}; + await task.update(`Copying batch ${i + 1}/${batches.length}`); + await task.run(`copy-${i}`, () => copyBatch(batch)); + } + await task.sleep("settle", 30); // waits without holding a function open + const report = await task.run("verify", () => verifyCopy(workspaceId)); + return { content: [{ type: "text", text: report }] }; + }, +); ``` Put side effects inside `task.run`. Code outside a step runs again on every invocation, and each @@ -307,14 +313,14 @@ step is still one invocation, so keep every step within your function limit. | --- | --- | --- | | `store` | required | `RedisTaskStore`, or your own `TaskStore` | | `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | -| `principal` | required | `({ auth, request }) => string \| undefined`, may be async. The caller's id; `undefined` refuses the call. | +| `principal` | required | `({ auth, request }) => string`, may be async. The caller's id; throw to refuse the call. | | `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | | `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | | `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | | `onSettle` | none | `(task) => void`. Called once when a task completes, fails or is cancelled. | **`tasks.define(name, config, handler)` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, -`pollIntervalMs`, `queuedMessage`, `completedMessage` and `idempotencyKey`. +`pollIntervalMs`, `queuedMessage` and `completedMessage`. ## Push instead of poll @@ -331,7 +337,7 @@ const taskFinished = taskFinishedEvent(events); export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => auth?.extra?.userId as string | undefined, + principal, onSettle: taskFinished.onSettle, }); ``` From e2845e782936098eb313957e9c6b201235dc7e90 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Thu, 8 Oct 2026 17:33:40 +0300 Subject: [PATCH 08/16] DX-3022: docs: match the simplified mcp-toolkit API - events: emit(payload, { eventId? }) only; every input field is a payload field; authorize gets the event's values at delivery; drop to/personal/ match/explicit args, principal context, task.finished, timeoutMs/defaults and QStashDelivery retries/headers options. - tasks: drop onSettle/taskFinishedEvent, toolNames, queuedMessage, per-tool ttlMs/pollIntervalMs and ttlMs: null. - principal defined once in lib/auth.ts with the SDK's AuthInfo; the MCP route types authInfo as AuthInfo. - New custom backend interfaces, Workflow signatures bound to the URL, MCP_EVENTS_SECRET_KEY >= 32 bytes, every IP literal refused. - Minimal snippets, details in Accordions, as in the package README. Claude-Session: https://claude.ai/code/session_012geGTRLRnPqYtaASmBdhE1 --- llms-full.txt | 1540 ++++++++++++++-------------- redis/sdks/agentkit/mcp-events.mdx | 364 +++---- redis/sdks/agentkit/mcp-tasks.mdx | 422 ++++---- 3 files changed, 1136 insertions(+), 1190 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index dfc5b7720..690219d37 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -87193,9 +87193,10 @@ signing and retries: subscriptions live in Upstash Redis, and every delivery goe [QStash](/docs/qstash/overall/getstarted), which retries failed webhooks with backoff. - MCP Events is a draft. As of October 2026, ChatGPT is the host that subscribes to it (webhook - delivery, in Work chats), following [OpenAI's MCP Events guide](https://developers.openai.com/plugins/build/mcp-events). - Claude Code, Cursor and OpenCode don't subscribe yet. + MCP Events is a draft. As of October 2026, ChatGPT is the only widely used host that subscribes to + it (webhook delivery, in Work chats), following [OpenAI's MCP Events 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. ```bash @@ -87204,42 +87205,51 @@ npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod ## Quickstart -Three files in a Next.js app. +Four files in a Next.js app. -### 1. Define the event layer and your events +### 1. Identify the caller + +Every subscription belongs to the user who subscribed. `principal` returns that user's id, read from +the token your MCP route has verified, and throws when there is none. If you also use +[MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks), both layers share this function: + +```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; +} +``` + +### 2. Define the event layer and your 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` }), - // Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 - secretKey: process.env.MCP_EVENTS_SECRET_KEY, - // Who is calling. `auth` is the verified token your MCP route passes to the SDK; see - // "Users" in the MCP Tasks docs for how it's populated. - principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return userId; - }, + principal, }); export const commentCreated = events.define("comment.created", { - description: "A new review comment was added to a document.", - // What a subscriber filters on. - input: z.object({ documentId: z.string() }), - // What every delivery carries. - payload: z.object({ documentId: z.string(), commentId: z.string(), text: z.string() }), - // Required: who may subscribe with these arguments and receive what they match. - // Runs on every subscribe and refresh, and again before every delivery. + description: "A new comment was added to a document.", + input: z.object({ documentId: z.string().optional() }), // what a subscriber may filter on + payload: z.object({ documentId: z.string(), text: z.string() }), + // May this user see comments on this document? authorize: (args, { principal }) => canRead(principal, args.documentId), }); ``` -### 2. Register the events on your MCP server +`authorize` is required. See [Matching and authorize](#matching-and-authorize) for when it runs. + +### 3. Register the events on your MCP server ```ts lib/server.ts import { McpServer } from "@modelcontextprotocol/server"; @@ -87247,16 +87257,29 @@ import { events } from "./events"; export function createServer() { const server = new McpServer({ name: "docs", version: "1.0.0" }); - // Adds events/list, events/subscribe and events/unsubscribe next to your tools. - events.register(server); + events.register(server); // adds events/list, events/subscribe and events/unsubscribe return server; } ``` -Serve it with `createMcpHandler(() => createServer())` as usual. Nothing else changes on the MCP -endpoint. +Serve it from your MCP route with the verified token, as in the +[MCP Tasks quickstart](/docs/redis/sdks/agentkit/mcp-tasks#quickstart): + +```ts app/api/mcp/route.ts +import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; +import { createServer } from "../../../lib/server"; -### 3. Add the delivery endpoint +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 }); +} +``` + +### 4. Add the delivery endpoint ```ts app/api/events/route.ts import { events } from "../../../lib/events"; @@ -87264,21 +87287,20 @@ import { events } from "../../../lib/events"; export const POST = events.createDeliveryHandler(); ``` -QStash delivers each event here. The handler verifies the QStash signature, signs the envelope -with the subscriber's secret, POSTs it to the host, and answers `500` when the host's callback -failed so QStash retries. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`. +QStash delivers each event here. The handler verifies the QStash signature, signs the envelope with +the subscriber's secret and POSTs it to the host. ### Emit Call `emit` wherever the change happens: a route, a webhook from your own app, a background job. ```ts -await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); +await commentCreated.emit({ documentId: "doc_123", text: "Ship it?" }); ``` -`emit` is typed by the `payload` schema and validates against it. It reaches every subscription -whose arguments match (everyone watching `doc_123`), checks `authorize` again for each subscriber -before delivering, and returns the event id and how many subscriptions matched. +`emit` is typed by the `payload` schema and validates against it. Every subscription that matches +the payload, and that `authorize` still allows for `documentId: "doc_123"`, gets a signed webhook. +It returns `{ eventId }`. ### Environment variables @@ -87286,6 +87308,7 @@ before delivering, and returns the event id and how many subscriptions matched. UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... QSTASH_TOKEN=... +# Required: the delivery endpoint refuses to run without them. QSTASH_CURRENT_SIGNING_KEY=... QSTASH_NEXT_SIGNING_KEY=... # Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 @@ -87300,205 +87323,152 @@ APP_URL=https://your-app.vercel.app `whsec_` signing secret. 2. For a new callback or secret, your server POSTs a signed verification challenge and requires it echoed back, then stores the subscription and answers with its `id` and a `refreshBefore` time. -3. When you `emit`, every matching subscription that `authorize` still allows gets a POST with the event envelope and - [Standard Webhooks](https://www.standardwebhooks.com/) headers: +3. When you `emit`, every matching subscription that `authorize` still allows gets a POST with the + event envelope and [Standard Webhooks](https://www.standardwebhooks.com/) headers: ```json { "eventId": "evt_7c1…", "name": "comment.created", "timestamp": "2026-10-07T12:05:00Z", - "data": { "documentId": "doc_123", "commentId": "c_9", "text": "Ship it?" }, + "data": { "documentId": "doc_123", "text": "Ship it?" }, "cursor": null } ``` -The event id stays the same across retries, so the host can drop duplicates. The signature is -computed fresh on every attempt. - -## Matching - -A subscription matches when every argument it gave equals the emitted value. Emitting -`{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of -`{ repo: "a", branch: "main" }`, and of `{}`. - -The values come from the payload fields named in the input schema, so when the payload carries -every input field, `emit(payload)` needs nothing else. When it doesn't, the type of `emit` makes -`args` required, and an emit with no value for a required input field throws, so a filter can never -silently match nothing: - -```ts -const replyPosted = events.define("reply.posted", { - description: "A reply was posted in a thread.", - input: z.object({ threadId: z.string() }), - payload: z.object({ text: z.string() }), // no threadId here - authorize: (args, { principal }) => canReadThread(principal, args.threadId), -}); - -await replyPosted.emit({ text: "Agreed" }, { args: { threadId } }); // `args` is required -``` - -Other options: - -```ts -await commentCreated.emit(payload, { - to: ["alice", "carol"], // only these users' subscriptions; optional - eventId: comment.id, // emitting the same id twice is deduplicated -}); -``` - -For conditions that exact matching can't express, add `match: (args, payload) => boolean` to the -event definition. - -## Users and subscriptions - -Three things decide who gets an event: the callback URL says **where** it goes, the subscription's -arguments say **what**, and `authorize` says **who may**. - -**The host routes to its user.** Every subscription carries a callback URL and signing secret that -the host generated for it. ChatGPT, for example, sends a unique -`https://connectors.api.openai.com/webhook/mcp-events/` per monitor. Posting to that URL -reaches the right user's agent, so your server never needs to know who the host user is. Two users -subscribing to the same event produce two subscriptions and two deliveries. - -**Your server knows who subscribed.** `events/subscribe` arrives with the same auth as any other MCP -request: the `AuthInfo` your route verified and passed to `handler.fetch(request, { authInfo })` -(see [Users](/docs/redis/sdks/agentkit/mcp-tasks#users)). `principal({ auth, request })` turns it into -the subscriber's id, which is stored on the subscription and becomes part of its id. - -**`authorize` decides who may, and keeps deciding.** It is required, and runs twice: - -| When | What it gets | -| --- | --- | -| Every subscribe and refresh | `args`, `{ principal, context, phase: "subscribe", auth, request }` | -| Before every delivery | `args`, `{ principal, context, phase: "deliver" }` | - -The token is never stored, so there is no `auth` or `request` behind a delivery. The second check is -what stops events when someone loses access after subscribing; a delivery it refuses is dropped, not -retried. An event any authenticated caller may hear says so with `authorize: () => true`. - -**Context for the delivery check.** Return `{ id, context }` from `principal` instead of a string -to store small, non-secret data (an org id, a role) with the subscription. `authorize` gets it back -as `context`: - -```ts -const events = createEventLayer({ - store, - delivery, - principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return { id: userId, context: { orgId: auth?.extra?.orgId } }; // never the token - }, -}); - -const memoPosted = events.define("memo.posted", { - description: "A memo was posted to your org.", - input: z.object({ orgId: z.string() }), - payload: z.object({ orgId: z.string(), text: z.string() }), - authorize: (args, { context }) => context.orgId === args.orgId, -}); -``` - -`context` is a snapshot from subscribe time, capped at 4 KB. For revocation, check your own data -with `principal` rather than trusting the snapshot. +QStash retries failed deliveries with backoff. The event id stays the same across retries, so the +host can drop duplicates, and the signature is computed fresh on every attempt. -**Personal events use `to`.** Some events belong to one user, like "your export finished". Mark them -`personal: true`, and every `emit` must name the user with `to`; the type requires it, and so does -a runtime check. A subscription with no arguments then only hears about its own user's events: +## Matching and authorize -```ts -const exportFinished = events.define("export.finished", { - description: "Your export finished.", - payload: z.object({ exportId: z.string(), url: z.string() }), - personal: true, - authorize: () => true, // `to` already limits it to the export's owner -}); +Every `input` field must also be a `payload` field: the payload is the one place an event's values +come from, and the same values are used to route it and to authorize it. A subscription matches when +each argument it gave equals the payload's value. Emitting `{ documentId: "doc_123", text }` reaches +subscribers of `{ documentId: "doc_123" }` and of `{}`. -await exportFinished.emit({ exportId, url }, { to: userId }); -``` +`authorize(args, caller)` runs twice: -Unsubscribing recomputes the id from the caller's principal, so only the subscriber can remove a -subscription. +* **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. - - `principal` is required, and there are no anonymous subscriptions: when it throws, - subscribe and unsubscribe are refused with reason `not_authenticated`, before any challenge is - sent. A server with no users of its own passes `principal: () => "local"`. - +The token is never stored, so the delivery-time check gets no `auth` or `request`. +`authorize: () => true` lets every authenticated subscriber hear every matching event. -## Task finished events + + Pass `{ eventId }` as the second argument to `emit`. Emitting the same id twice delivers once: -If you use [MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks), a `task.finished` event lets an -event-capable host stop polling `task_status`: - -```ts -import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; + ```ts + await commentCreated.emit({ documentId, text }, { eventId: comment.id }); + ``` + -const taskFinished = taskFinishedEvent(events); + + **The host routes to its user.** Every subscription carries a callback URL and signing secret that + the host generated for it. ChatGPT, for example, sends a unique + `https://connectors.api.openai.com/webhook/mcp-events/` per monitor. Posting to that URL + reaches the right user's agent, so your server never needs to know who the host user is. Two users + subscribing to the same event produce two subscriptions and two deliveries. -export const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); -``` + **Your server knows who subscribed.** `events/subscribe` arrives with the same auth as any other + MCP request, and `principal` turns it into the subscriber's id. `events/subscribe` and + `events/unsubscribe` resolve `principal` before anything else, so an unidentified caller learns + nothing about your events and is refused with reason `not_authenticated`. A subscription's id is a + hash of subscriber, callback URL, event and arguments, so a user can only refresh or remove their + own. A server with no users of its own passes `principal: () => "local"`. -It fires once when a task completes, fails or is cancelled. It is a personal event, emitted `to` the -task's owner, so only their subscriptions get it. We haven't tested it with ChatGPT yet. A host subscribes with no arguments for every task, or with `taskId` for one. The -payload includes the result when it fits in the 256 KiB envelope. + Every recipient of an emit gets the same payload, so don't put data in it that only some matching + subscribers may see. + ## Security -* **Callback URLs** must be `https` on a public host. `localhost`, `.local` and `.internal` names - (trailing dots included), private, link-local, reserved and documentation IPs (also when embedded - in IPv6), and credentials in the URL are refused, and redirects are never followed. The checks don't - resolve DNS, so add egress filtering in production. -* **Verification:** a subscription is stored only after the callback echoes a signed challenge. - Every failure returns the same error `-32015`, so a subscriber can't probe your network; the - detail goes to your server logs. -* **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret - skips the challenge; a new secret verifies again. -* **Authorization:** `authorize` runs on every subscribe and refresh, and again before every - delivery. -* **Delivery endpoint:** verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` variables (or - a `receiver` you pass). Without them it throws on the first request; it never sends unverified - deliveries. + + * **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. + * **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. + * **The delivery endpoint.** It verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` + variables (or a `receiver` you pass). Without them it throws on the first request instead of + sending unverified deliveries. A bad signature or body gets `489` with + `Upstash-NonRetryable-Error: true`. + * **Lifetime.** A subscription lasts 7 days by default and 30 days at most. + - `allowInsecureCallbacks: true` accepts `http://` and private addresses. Use it only for local + `allowInsecureCallbacks: true` turns the callback URL checks off. Use it only for local development, never in production. -## Host responses + + | Callback answers | What happens | + | --- | --- | + | `2xx` | Delivered. | + | `410 Gone` | The subscription is deleted. | + | `413`, or a redirect | The event is dropped, not retried. | + | Anything else, or no answer | QStash retries with backoff. | + -| Callback answers | What happens | -| --- | --- | -| `2xx` | Delivered. | -| `410 Gone` | The subscription is deleted. | -| `413`, or a redirect | The event is dropped, not retried. | -| Anything else, or no answer | QStash retries with backoff. | + + **`mcp-events:sub:`** is the subscription (`event`, `args`, `url`, `encryptedSecret`, + `subscriber`, `createdAt`, `expiresAt`), expiring with it. **`mcp-events:idx:`** is a sorted + set of the event's subscription ids, scored by expiry. + + 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`. Anyone with write access to your Redis can + change a callback URL, so treat the Redis credentials like any other production secret. + ## Options -**`createEventLayer`** + + **`createEventLayer`**: `store`, `delivery` and `principal` are required, and so is `secretKey` + unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `allowInsecureCallbacks`. -| Option | Default | | -| --- | --- | --- | -| `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | -| `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `principal` | required | `({ auth, request }) => id \| { id, context }`, may be async. The subscriber; throw to refuse. | -| `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | -| `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | -| `defaults.maxTtlMs` | 30 days | The most the server grants | -| `allowInsecureCallbacks` | `false` | Local development only | -| `timeoutMs` | 10 seconds | Timeout for each POST to a callback | + **`events.define(name, config)`**: `description`, `payload` and `authorize` are required. + Optional: `title`, `input`. + + **`RedisSubscriptionStore`**: `redis` (defaults to one from env), `prefix` (`mcp-events:`), + `enableTelemetry`. -**`define` config:** `description`, `payload` and `authorize` (required), plus optional `title`, -`input`, `personal` and `match`. + **`QStashDelivery`**: `url` is required. Optional: `qstash`, `receiver`, `enableTelemetry`. + Deliveries are retried 3 times with QStash's backoff. + -**`QStashDelivery`:** `url` (required), `retries` (default 3), `retryDelay`, `headers`, `qstash`, -`receiver`. + + `@upstash/mcp-toolkit/events` doesn't import anything from Upstash. A backend implements one of + these interfaces (types exported from the same entry point): -**`RedisSubscriptionStore`:** `redis`, `prefix` (default `mcp-events:`). + ```ts + interface SubscriptionStore { + put(subscription: Subscription): Promise; + get(id: string): Promise; + delete(subscription: { id: string; event: string }): Promise; + find(event: string): Promise; // every live subscription to the event + } + + interface EventDelivery { + enqueue(jobs: DeliveryJob[]): Promise; + createDeliveryHandler(send: SendJob): (request: Request) => Promise; + } + ``` + + The entry point also exports `verifyWebhook` (Standard Webhooks) for writing a receiver. + -Poll and stream delivery modes and replay cursors from the draft aren't implemented. +Not implemented: the draft's poll and stream delivery modes (`events/subscribe` refuses them) and +event replay (`cursor` is always `null`). ## Example @@ -87521,9 +87491,10 @@ signing and retries: subscriptions live in Upstash Redis, and every delivery goe [QStash](/docs/qstash/overall/getstarted), which retries failed webhooks with backoff. - MCP Events is a draft. As of October 2026, ChatGPT is the host that subscribes to it (webhook - delivery, in Work chats), following [OpenAI's MCP Events guide](https://developers.openai.com/plugins/build/mcp-events). - Claude Code, Cursor and OpenCode don't subscribe yet. + MCP Events is a draft. As of October 2026, ChatGPT is the only widely used host that subscribes to + it (webhook delivery, in Work chats), following [OpenAI's MCP Events 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. ```bash @@ -87532,42 +87503,51 @@ npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod ## Quickstart -Three files in a Next.js app. +Four files in a Next.js app. -### 1. Define the event layer and your events +### 1. Identify the caller + +Every subscription belongs to the user who subscribed. `principal` returns that user's id, read from +the token your MCP route has verified, and throws when there is none. If you also use +[MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks), both layers share this function: + +```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; +} +``` + +### 2. Define the event layer and your 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` }), - // Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 - secretKey: process.env.MCP_EVENTS_SECRET_KEY, - // Who is calling. `auth` is the verified token your MCP route passes to the SDK; see - // "Users" in the MCP Tasks docs for how it's populated. - principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return userId; - }, + principal, }); export const commentCreated = events.define("comment.created", { - description: "A new review comment was added to a document.", - // What a subscriber filters on. - input: z.object({ documentId: z.string() }), - // What every delivery carries. - payload: z.object({ documentId: z.string(), commentId: z.string(), text: z.string() }), - // Required: who may subscribe with these arguments and receive what they match. - // Runs on every subscribe and refresh, and again before every delivery. + description: "A new comment was added to a document.", + input: z.object({ documentId: z.string().optional() }), // what a subscriber may filter on + payload: z.object({ documentId: z.string(), text: z.string() }), + // May this user see comments on this document? authorize: (args, { principal }) => canRead(principal, args.documentId), }); ``` -### 2. Register the events on your MCP server +`authorize` is required. See [Matching and authorize](#matching-and-authorize) for when it runs. + +### 3. Register the events on your MCP server ```ts lib/server.ts import { McpServer } from "@modelcontextprotocol/server"; @@ -87575,16 +87555,29 @@ import { events } from "./events"; export function createServer() { const server = new McpServer({ name: "docs", version: "1.0.0" }); - // Adds events/list, events/subscribe and events/unsubscribe next to your tools. - events.register(server); + events.register(server); // adds events/list, events/subscribe and events/unsubscribe return server; } ``` -Serve it with `createMcpHandler(() => createServer())` as usual. Nothing else changes on the MCP -endpoint. +Serve it from your MCP route with the verified token, as in the +[MCP Tasks quickstart](/docs/redis/sdks/agentkit/mcp-tasks#quickstart): + +```ts app/api/mcp/route.ts +import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; +import { createServer } from "../../../lib/server"; + +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 }); +} +``` -### 3. Add the delivery endpoint +### 4. Add the delivery endpoint ```ts app/api/events/route.ts import { events } from "../../../lib/events"; @@ -87592,21 +87585,20 @@ import { events } from "../../../lib/events"; export const POST = events.createDeliveryHandler(); ``` -QStash delivers each event here. The handler verifies the QStash signature, signs the envelope -with the subscriber's secret, POSTs it to the host, and answers `500` when the host's callback -failed so QStash retries. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`. +QStash delivers each event here. The handler verifies the QStash signature, signs the envelope with +the subscriber's secret and POSTs it to the host. ### Emit Call `emit` wherever the change happens: a route, a webhook from your own app, a background job. ```ts -await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); +await commentCreated.emit({ documentId: "doc_123", text: "Ship it?" }); ``` -`emit` is typed by the `payload` schema and validates against it. It reaches every subscription -whose arguments match (everyone watching `doc_123`), checks `authorize` again for each subscriber -before delivering, and returns the event id and how many subscriptions matched. +`emit` is typed by the `payload` schema and validates against it. Every subscription that matches +the payload, and that `authorize` still allows for `documentId: "doc_123"`, gets a signed webhook. +It returns `{ eventId }`. ### Environment variables @@ -87614,6 +87606,7 @@ before delivering, and returns the event id and how many subscriptions matched. UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... QSTASH_TOKEN=... +# Required: the delivery endpoint refuses to run without them. QSTASH_CURRENT_SIGNING_KEY=... QSTASH_NEXT_SIGNING_KEY=... # Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 @@ -87628,205 +87621,152 @@ APP_URL=https://your-app.vercel.app `whsec_` signing secret. 2. For a new callback or secret, your server POSTs a signed verification challenge and requires it echoed back, then stores the subscription and answers with its `id` and a `refreshBefore` time. -3. When you `emit`, every matching subscription that `authorize` still allows gets a POST with the event envelope and - [Standard Webhooks](https://www.standardwebhooks.com/) headers: +3. When you `emit`, every matching subscription that `authorize` still allows gets a POST with the + event envelope and [Standard Webhooks](https://www.standardwebhooks.com/) headers: ```json { "eventId": "evt_7c1…", "name": "comment.created", "timestamp": "2026-10-07T12:05:00Z", - "data": { "documentId": "doc_123", "commentId": "c_9", "text": "Ship it?" }, + "data": { "documentId": "doc_123", "text": "Ship it?" }, "cursor": null } ``` -The event id stays the same across retries, so the host can drop duplicates. The signature is -computed fresh on every attempt. - -## Matching - -A subscription matches when every argument it gave equals the emitted value. Emitting -`{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of -`{ repo: "a", branch: "main" }`, and of `{}`. - -The values come from the payload fields named in the input schema, so when the payload carries -every input field, `emit(payload)` needs nothing else. When it doesn't, the type of `emit` makes -`args` required, and an emit with no value for a required input field throws, so a filter can never -silently match nothing: - -```ts -const replyPosted = events.define("reply.posted", { - description: "A reply was posted in a thread.", - input: z.object({ threadId: z.string() }), - payload: z.object({ text: z.string() }), // no threadId here - authorize: (args, { principal }) => canReadThread(principal, args.threadId), -}); - -await replyPosted.emit({ text: "Agreed" }, { args: { threadId } }); // `args` is required -``` - -Other options: - -```ts -await commentCreated.emit(payload, { - to: ["alice", "carol"], // only these users' subscriptions; optional - eventId: comment.id, // emitting the same id twice is deduplicated -}); -``` +QStash retries failed deliveries with backoff. The event id stays the same across retries, so the +host can drop duplicates, and the signature is computed fresh on every attempt. -For conditions that exact matching can't express, add `match: (args, payload) => boolean` to the -event definition. +## Matching and authorize -## Users and subscriptions +Every `input` field must also be a `payload` field: the payload is the one place an event's values +come from, and the same values are used to route it and to authorize it. A subscription matches when +each argument it gave equals the payload's value. Emitting `{ documentId: "doc_123", text }` reaches +subscribers of `{ documentId: "doc_123" }` and of `{}`. -Three things decide who gets an event: the callback URL says **where** it goes, the subscription's -arguments say **what**, and `authorize` says **who may**. +`authorize(args, caller)` runs twice: -**The host routes to its user.** Every subscription carries a callback URL and signing secret that -the host generated for it. ChatGPT, for example, sends a unique -`https://connectors.api.openai.com/webhook/mcp-events/` per monitor. Posting to that URL -reaches the right user's agent, so your server never needs to know who the host user is. Two users -subscribing to the same event produce two subscriptions and two deliveries. +* **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. -**Your server knows who subscribed.** `events/subscribe` arrives with the same auth as any other MCP -request: the `AuthInfo` your route verified and passed to `handler.fetch(request, { authInfo })` -(see [Users](/docs/redis/sdks/agentkit/mcp-tasks#users)). `principal({ auth, request })` turns it into -the subscriber's id, which is stored on the subscription and becomes part of its id. +The token is never stored, so the delivery-time check gets no `auth` or `request`. +`authorize: () => true` lets every authenticated subscriber hear every matching event. -**`authorize` decides who may, and keeps deciding.** It is required, and runs twice: - -| When | What it gets | -| --- | --- | -| Every subscribe and refresh | `args`, `{ principal, context, phase: "subscribe", auth, request }` | -| Before every delivery | `args`, `{ principal, context, phase: "deliver" }` | - -The token is never stored, so there is no `auth` or `request` behind a delivery. The second check is -what stops events when someone loses access after subscribing; a delivery it refuses is dropped, not -retried. An event any authenticated caller may hear says so with `authorize: () => true`. - -**Context for the delivery check.** Return `{ id, context }` from `principal` instead of a string -to store small, non-secret data (an org id, a role) with the subscription. `authorize` gets it back -as `context`: - -```ts -const events = createEventLayer({ - store, - delivery, - principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return { id: userId, context: { orgId: auth?.extra?.orgId } }; // never the token - }, -}); - -const memoPosted = events.define("memo.posted", { - description: "A memo was posted to your org.", - input: z.object({ orgId: z.string() }), - payload: z.object({ orgId: z.string(), text: z.string() }), - authorize: (args, { context }) => context.orgId === args.orgId, -}); -``` - -`context` is a snapshot from subscribe time, capped at 4 KB. For revocation, check your own data -with `principal` rather than trusting the snapshot. - -**Personal events use `to`.** Some events belong to one user, like "your export finished". Mark them -`personal: true`, and every `emit` must name the user with `to`; the type requires it, and so does -a runtime check. A subscription with no arguments then only hears about its own user's events: - -```ts -const exportFinished = events.define("export.finished", { - description: "Your export finished.", - payload: z.object({ exportId: z.string(), url: z.string() }), - personal: true, - authorize: () => true, // `to` already limits it to the export's owner -}); - -await exportFinished.emit({ exportId, url }, { to: userId }); -``` - -Unsubscribing recomputes the id from the caller's principal, so only the subscriber can remove a -subscription. - - - `principal` is required, and there are no anonymous subscriptions: when it throws, - subscribe and unsubscribe are refused with reason `not_authenticated`, before any challenge is - sent. A server with no users of its own passes `principal: () => "local"`. - + + Pass `{ eventId }` as the second argument to `emit`. Emitting the same id twice delivers once: -## Task finished events - -If you use [MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks), a `task.finished` event lets an -event-capable host stop polling `task_status`: + ```ts + await commentCreated.emit({ documentId, text }, { eventId: comment.id }); + ``` + -```ts -import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; + + **The host routes to its user.** Every subscription carries a callback URL and signing secret that + the host generated for it. ChatGPT, for example, sends a unique + `https://connectors.api.openai.com/webhook/mcp-events/` per monitor. Posting to that URL + reaches the right user's agent, so your server never needs to know who the host user is. Two users + subscribing to the same event produce two subscriptions and two deliveries. -const taskFinished = taskFinishedEvent(events); + **Your server knows who subscribed.** `events/subscribe` arrives with the same auth as any other + MCP request, and `principal` turns it into the subscriber's id. `events/subscribe` and + `events/unsubscribe` resolve `principal` before anything else, so an unidentified caller learns + nothing about your events and is refused with reason `not_authenticated`. A subscription's id is a + hash of subscriber, callback URL, event and arguments, so a user can only refresh or remove their + own. A server with no users of its own passes `principal: () => "local"`. -export const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); -``` - -It fires once when a task completes, fails or is cancelled. It is a personal event, emitted `to` the -task's owner, so only their subscriptions get it. We haven't tested it with ChatGPT yet. A host subscribes with no arguments for every task, or with `taskId` for one. The -payload includes the result when it fits in the 256 KiB envelope. + Every recipient of an emit gets the same payload, so don't put data in it that only some matching + subscribers may see. + ## Security -* **Callback URLs** must be `https` on a public host. `localhost`, `.local` and `.internal` names - (trailing dots included), private, link-local, reserved and documentation IPs (also when embedded - in IPv6), and credentials in the URL are refused, and redirects are never followed. The checks don't - resolve DNS, so add egress filtering in production. -* **Verification:** a subscription is stored only after the callback echoes a signed challenge. - Every failure returns the same error `-32015`, so a subscriber can't probe your network; the - detail goes to your server logs. -* **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret - skips the challenge; a new secret verifies again. -* **Authorization:** `authorize` runs on every subscribe and refresh, and again before every - delivery. -* **Delivery endpoint:** verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` variables (or - a `receiver` you pass). Without them it throws on the first request; it never sends unverified - deliveries. + + * **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. + * **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. + * **The delivery endpoint.** It verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` + variables (or a `receiver` you pass). Without them it throws on the first request instead of + sending unverified deliveries. A bad signature or body gets `489` with + `Upstash-NonRetryable-Error: true`. + * **Lifetime.** A subscription lasts 7 days by default and 30 days at most. + - `allowInsecureCallbacks: true` accepts `http://` and private addresses. Use it only for local + `allowInsecureCallbacks: true` turns the callback URL checks off. Use it only for local development, never in production. -## Host responses + + | Callback answers | What happens | + | --- | --- | + | `2xx` | Delivered. | + | `410 Gone` | The subscription is deleted. | + | `413`, or a redirect | The event is dropped, not retried. | + | Anything else, or no answer | QStash retries with backoff. | + -| Callback answers | What happens | -| --- | --- | -| `2xx` | Delivered. | -| `410 Gone` | The subscription is deleted. | -| `413`, or a redirect | The event is dropped, not retried. | -| Anything else, or no answer | QStash retries with backoff. | + + **`mcp-events:sub:`** is the subscription (`event`, `args`, `url`, `encryptedSecret`, + `subscriber`, `createdAt`, `expiresAt`), expiring with it. **`mcp-events:idx:`** is a sorted + set of the event's subscription ids, scored by expiry. + + 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`. Anyone with write access to your Redis can + change a callback URL, so treat the Redis credentials like any other production secret. + ## Options -**`createEventLayer`** + + **`createEventLayer`**: `store`, `delivery` and `principal` are required, and so is `secretKey` + unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `allowInsecureCallbacks`. -| Option | Default | | -| --- | --- | --- | -| `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | -| `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `principal` | required | `({ auth, request }) => id \| { id, context }`, may be async. The subscriber; throw to refuse. | -| `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | -| `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | -| `defaults.maxTtlMs` | 30 days | The most the server grants | -| `allowInsecureCallbacks` | `false` | Local development only | -| `timeoutMs` | 10 seconds | Timeout for each POST to a callback | + **`events.define(name, config)`**: `description`, `payload` and `authorize` are required. + Optional: `title`, `input`. + + **`RedisSubscriptionStore`**: `redis` (defaults to one from env), `prefix` (`mcp-events:`), + `enableTelemetry`. + + **`QStashDelivery`**: `url` is required. Optional: `qstash`, `receiver`, `enableTelemetry`. + Deliveries are retried 3 times with QStash's backoff. + -**`define` config:** `description`, `payload` and `authorize` (required), plus optional `title`, -`input`, `personal` and `match`. + + `@upstash/mcp-toolkit/events` doesn't import anything from Upstash. A backend implements one of + these interfaces (types exported from the same entry point): -**`QStashDelivery`:** `url` (required), `retries` (default 3), `retryDelay`, `headers`, `qstash`, -`receiver`. + ```ts + interface SubscriptionStore { + put(subscription: Subscription): Promise; + get(id: string): Promise; + delete(subscription: { id: string; event: string }): Promise; + find(event: string): Promise; // every live subscription to the event + } + + interface EventDelivery { + enqueue(jobs: DeliveryJob[]): Promise; + createDeliveryHandler(send: SendJob): (request: Request) => Promise; + } + ``` -**`RedisSubscriptionStore`:** `redis`, `prefix` (default `mcp-events:`). + The entry point also exports `verifyWebhook` (Standard Webhooks) for writing a receiver. + -Poll and stream delivery modes and replay cursors from the draft aren't implemented. +Not implemented: the draft's poll and stream delivery modes (`events/subscribe` refuses them) and +event replay (`cursor` is always `null`). ## Example @@ -87842,10 +87782,10 @@ Source: https://upstash.com/docs/redis/sdks/agentkit/mcp-tasks An MCP tool call is a request that waits for its response. When the work takes minutes, something gives up first: the client's tool timeout, your serverless function's limit, or the model. -`@upstash/mcp-toolkit/tasks` turns a slow tool into a task. The tool answers immediately with a task id, the -work runs on [QStash](/docs/qstash/overall/getstarted) or [Upstash Workflow](/docs/workflow/getstarted) so it -survives restarts and the client's tool timeout, and the task record lives in Upstash Redis. The model checks on it -with a shared `task_status` tool and can stop it with `task_cancel`. +`@upstash/mcp-toolkit/tasks` turns a slow tool into a task. The tool answers immediately with a task +id, the work runs on [QStash](/docs/qstash/overall/getstarted) or [Upstash Workflow](/docs/workflow/getstarted) +so it survives restarts and the client's tool timeout, and the task record lives in Upstash Redis. +The model checks on it with a shared `task_status` tool and can stop it with `task_cancel`. These are ordinary MCP tools, so they work in every client today: Claude Code, Codex, Cursor, OpenCode and ChatGPT. No client capability is required. The toolkit sits on top of the official MCP @@ -87857,70 +87797,80 @@ npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod ## Quickstart -Three files in a Next.js app. +Four files in a Next.js app. + +### 1. Identify the caller + +Every task belongs to the user who started it. `principal` returns that user's id, read from the +token your MCP route has verified, and throws 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; +} +``` + +See [Users](#users) for where `auth` comes from. -### 1. Define the task layer, the task and the server +### 2. Define the task layer, the task and the server ```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"; -// Where the task record lives, how the work is delivered, and who is calling. export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return userId; - }, + principal, }); -// Defined at module scope, so every instance knows the handler, including -// an /api/execute instance that never serves an MCP request. +// 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() }), - }, - // The handler: your long-running work. It runs later, in /api/execute, - // when QStash delivers the task — not inside the tool call. + { description: "Generates a report on a topic.", inputSchema: z.object({ topic: z.string() }) }, async ({ topic }, task) => { - await task.update("Researching sources"); - const report = await writeReport(topic); - return { content: [{ type: "text", text: report }] }; + await task.update("Reading sources"); // the model sees this when it polls + return { content: [{ type: "text", text: await writeReport(topic) }] }; }, ); -// Called per request. Adds generate_report, task_status and task_cancel. export function createServer() { const server = new McpServer({ name: "reports", version: "1.0.0" }); - tasks.register(server); + tasks.register(server); // adds generate_report, task_status and task_cancel return server; } ``` -`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. `principal` is -required, and `auth` is the verified token your route passes in; see [Users](#users). +The handler runs later, in `/api/execute`, when QStash delivers the task, not inside the tool call. +`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. -### 2. Serve the MCP endpoint +### 3. Serve the MCP endpoint ```ts app/api/mcp/route.ts -import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; import { createServer } from "../../../lib/tasks"; -const mcp = createMcpHandler(() => createServer()); +const handler = createMcpHandler(() => createServer()); -export const POST = (request: Request) => mcp.fetch(request); +export async function POST(request: Request) { + // Clerk, WorkOS, Auth0, your own. `principal` reads the user id from `authInfo.extra`. + const authInfo: AuthInfo | undefined = await verifyToken(request); + if (!authInfo) return new Response("Unauthorized", { status: 401 }); + return handler.fetch(request, { authInfo }); +} ``` -The task layer only registers tools, so this is the SDK's own handler, unchanged. It works the same -with [`mcp-handler`](https://www.npmjs.com/package/mcp-handler) or any transport. +The task layer only registers tools, so this is the SDK's own handler, unchanged. -### 3. Add the execute endpoint +### 4. Add the execute endpoint ```ts app/api/execute/route.ts import { tasks } from "../../../lib/tasks"; @@ -87928,14 +87878,8 @@ import { tasks } from "../../../lib/tasks"; export const POST = tasks.createExecuteHandler(); ``` -This is where QStash delivers each task. The handler it returns verifies the QStash signature, finds -the task's handler, runs it and stores the result. It answers `200` when done and `500` to ask QStash -for a retry. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`, since QStash -retries every other non-2xx. - -The signing keys are required. Without `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY` -(or a `receiver` you pass), the endpoint throws on its first request instead of running unverified -deliveries. The same applies to `WorkflowDispatcher`. +This is where QStash delivers each task. The handler verifies the QStash signature, runs the task's +handler and stores the result. ### Environment variables @@ -87943,6 +87887,7 @@ deliveries. The same applies to `WorkflowDispatcher`. UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... QSTASH_TOKEN=... +# Required: the execute endpoint refuses to run without them. QSTASH_CURRENT_SIGNING_KEY=... QSTASH_NEXT_SIGNING_KEY=... # Where QStash delivers tasks. Must be publicly reachable. @@ -87957,37 +87902,36 @@ APP_URL=https://your-app.vercel.app ## What the model sees -1. It calls `generate_report({ topic })` and gets back a `taskId`, `status: "working"`, and a line - telling it to call `task_status` in about two seconds. -2. `task_status({ taskId })` returns progress while the task runs, for example - `"Researching sources"`. -3. Once the task completes, `task_status` returns a one-line status followed by your handler's - `content`. +`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`. -`task_cancel({ taskId })` marks the task `cancelled` and stops any pending delivery. Each response -also carries the task object in `structuredContent`: + + ```jsonc + // generate_report: a handle, right away + { "content": [{ "type": "text", "text": "Started task 0e30…. Call task_status with taskId \"0e30…\" in about 2s to check on it." }], + "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 300000, "pollIntervalMs": 2000 } } -```json -{ - "taskId": "0e30…", - "status": "working", - "statusMessage": "Researching sources", - "pollIntervalMs": 2000, - "ttlMs": 300000 -} -``` + // 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" } } -A task is `working`, `completed`, `failed` or `cancelled`. The last three are final. + // …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 handler's `task` argument + 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. -| Member | What it does | -| --- | --- | -| `task.taskId` | The id of the running task. | -| `task.update(message)` | Writes a progress line. The model sees it on its next `task_status`. | -| `task.isCancelled()` | `true` once `task_cancel` was called, or the task expired. | + If the model stops polling, nothing is lost: the work finishes anyway, and the result can be read + until the task expires (5 minutes by default; set `defaults: { ttlMs }` on the layer). + + +## Cancellation -Cancellation is cooperative: your code keeps running until it checks. Check between steps: +`task_cancel` marks the task `cancelled` and stops any pending delivery. Cancelling is cooperative, +so running code only stops where it checks `task.isCancelled()`: ```ts async ({ sources }, task) => { @@ -88000,6 +87944,8 @@ async ({ sources }, task) => { }; ``` +`isCancelled()` also returns `true` once the task has expired. + Arguments are validated when the tool is called, then stored as JSON and handed to the handler later. Values that don't survive JSON, such as a `Date` produced by `z.coerce.date()`, arrive as @@ -88008,115 +87954,57 @@ async ({ sources }, task) => { ## Users -`principal` is required. It maps each call to a stable caller id, usually your user id. Each task -records who started it, and `task_status` / `task_cancel` only answer for that caller. Another -user's task id reads like an unknown one. +Each task records who started it, and `task_status` / `task_cancel` only answer for that caller. +Another user's task id reads exactly like an unknown one, so nobody can tell it exists. -`principal` receives `{ auth, request }` and may be async. - -### `auth`: the verified token - -`auth` is the MCP SDK's `AuthInfo`: - -```ts -interface AuthInfo { - token: string; - clientId: string; // the OAuth app, e.g. one id for every ChatGPT user - scopes: string[]; - expiresAt?: number; - extra?: Record; // free-form, filled by your verifier -} -``` +`principal` receives `{ auth, request }` and may be async. `auth` is the MCP SDK's `AuthInfo`. The SDK +never fills it in from request headers: your MCP route does, after verifying the bearer token, by +passing it to `handler.fetch(request, { authInfo })` as in step 3. Put the user id in `extra` when you +build `authInfo`, and read it back in `principal`. -The SDK never fills it in from request headers. Your MCP route does, after verifying the bearer -token with your OAuth provider, by passing it to the handler: + + * **A throw refuses the call.** There is no anonymous mode: if `principal` throws, rejects, or + returns anything but a non-empty string, the tool 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. + * `principal` also receives `request`, for cookie or session apps. It is unverified, so check the + session yourself, and never trust a header like `x-user-id`: -```ts app/api/mcp/route.ts -import { createMcpHandler } from "@modelcontextprotocol/server"; -import { createServer } from "../../../lib/tasks"; - -const mcp = createMcpHandler(() => createServer()); - -export async function POST(request: Request) { - // Verify the bearer token with Clerk, WorkOS, Auth0 or your own auth server. - const authInfo = await verifyToken(request); - if (!authInfo) return new Response("Unauthorized", { status: 401 }); - return mcp.fetch(request, { authInfo }); -} -``` - -Put the user id in `extra` when you build `authInfo`, and read it back in `principal`, as in the -quickstart: - -```ts -principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return userId; -}, -``` - -Because the token was already checked, this is the path to prefer. Key on the user, not -`auth.clientId`: the client id identifies the OAuth app, which can be one id shared by every user of -a host like ChatGPT. - -### `request`: the raw HTTP request - -Apps that authenticate with a cookie or a session, rather than building an `AuthInfo`, can read the -request instead: - -```ts -principal: async ({ request }) => { - const session = await getSession(request); - if (!session) throw new Error("Not authenticated"); - return session.userId; -}, -``` - -The request is unverified. Check the session yourself, and never trust a header like `x-user-id` -that the caller can set. - -### No anonymous mode + ```ts + import type { Caller } from "@upstash/mcp-toolkit/tasks"; -`principal` must return an id; its type doesn't allow `undefined`. When it can't identify the -caller, it throws, and the tool call is refused with "Not authenticated". The toolkit fails closed -even if the types are bypassed: an answer without a non-empty string id is refused too. A server -with no users of its own passes `principal: () => "local"`. + export async function principal({ request }: Caller): Promise { + const session = await getSession(request); + if (!session) throw new Error("Not authenticated"); + return session.userId; + } + ``` - - Task arguments and results are stored in Redis as plain JSON for the task's `ttlMs`, which is - forever with `ttlMs: null`. Keep secrets out of them, or use a short TTL. - + * A server with no users of its own passes `principal: () => "local"`. + ## Work longer than one function invocation -A QStash delivery is one serverless invocation, so the handler has to finish within your platform's -function limit. If it is killed, QStash redelivers and the handler starts over. +With `QStashDispatcher`, 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. -For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, and finished steps -are replayed from a journal instead of re-run. The handler's `task` then also has the Workflow API -(`task.run`, `task.sleep`, `task.call`), inferred from the dispatcher with no extra typing: +`WorkflowDispatcher` 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. The +handler's `task` then also has `run`, `sleep` and `call`, inferred from the dispatcher: ```ts lib/tasks.ts -import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/upstash"; -import * as z from "zod"; export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal, // the same function as in the quickstart - // A TTL longer than the work. It runs from creation and is never extended; once it passes, - // the record is gone and isCancelled() returns true. The default is 5 minutes. - defaults: { ttlMs: 60 * 60 * 1000 }, + principal, + defaults: { ttlMs: 60 * 60 * 1000 }, // keep the record longer than the work takes }); tasks.define( "migrate_workspace", - { - description: "Copies a workspace's documents to the new storage, one batch at a time.", - inputSchema: z.object({ workspaceId: z.string() }), - }, + { description: "Copies a workspace to new storage.", inputSchema: z.object({ workspaceId: z.string() }) }, async ({ workspaceId }, task) => { const batches = await task.run("plan", () => listBatches(workspaceId)); for (const [i, batch] of batches.entries()) { @@ -88124,68 +88012,132 @@ tasks.define( await task.update(`Copying batch ${i + 1}/${batches.length}`); await task.run(`copy-${i}`, () => copyBatch(batch)); } - await task.sleep("settle", 30); // waits without holding a function open - const report = await task.run("verify", () => verifyCopy(workspaceId)); - return { content: [{ type: "text", text: report }] }; + return { content: [{ type: "text", text: "Done" }] }; }, ); ``` -Put side effects inside `task.run`. Code outside a step runs again on every invocation, and each -step is still one invocation, so keep every step within your function limit. + + The handler runs again from the top on every step, and finished steps are replayed from the + journal: -| | `QStashDispatcher` | `WorkflowDispatcher` | -| --- | --- | --- | -| Survives a crash | Yes, redelivered | Yes, replayed | -| Outlives one invocation | No | Yes, one invocation per step | -| On retry | Starts the handler over | Resumes from the last finished step | + * 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, so set `ttlMs` longer than the work takes. + + | | `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: true` 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. + This holds for `WorkflowDispatcher` too. Without signing keys (or a `receiver` you pass), the + route throws on its first request 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. + + +## Security and storage + + + The server sets a task's owner from `principal`, and no tool argument can set it. `task_status` and + `task_cancel` only accept UUID task ids, and compare the stored owner with the caller. Task ids are + random UUIDs. No tool lists tasks, and the execute route only accepts signed QStash deliveries and + never returns a task. + + Anyone with write access to your Redis can change an owner, so treat the Redis credentials like any + other production secret. + + + + **`mcp:task:`** is 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. + + A task message in QStash carries only `{ taskId }`. The toolkit never stores the caller's token or + the request. + ## Options -**`createTaskLayer`** + + **`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: + `defaults.ttlMs` (5 minutes) and `defaults.pollIntervalMs` (2 seconds). -| Option | Default | | -| --- | --- | --- | -| `store` | required | `RedisTaskStore`, or your own `TaskStore` | -| `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | -| `principal` | required | `({ auth, request }) => string`, may be async. The caller's id; throw to refuse the call. | -| `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | -| `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | -| `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | -| `onSettle` | none | `(task) => void`. Called once when a task completes, fails or is cancelled. | + **`tasks.define(name, config, handler)`**: `description` and `inputSchema` are required. Optional: + `title`, `completedMessage` (defaults to `"Completed"`). -**`tasks.define(name, config, handler)` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, -`pollIntervalMs`, `queuedMessage` and `completedMessage`. + **`RedisTaskStore`**: `redis` (defaults to one from env), `prefix` (`mcp:task:`), `enableTelemetry`. -## Push instead of poll + **`QStashDispatcher`**: `url` is required. Optional: `qstash`, `receiver`, `retries` (5), + `retryDelay`, `enableTelemetry`. -A host that supports MCP Events can get a webhook when a task finishes instead of polling. Wire the -`task.finished` event from [MCP Events](/docs/redis/sdks/agentkit/mcp-events) into `onSettle`; it fires to -the task's owner only: + **`WorkflowDispatcher`**: `url` is required. Optional: `client`, `qstash`, `receiver`, `retries`, + `enableTelemetry`. -```ts lib/tasks.ts -import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; -import { events } from "./events"; // your event layer + `receiver` defaults to one built from the `QSTASH_*_SIGNING_KEY` variables. 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`. + -const taskFinished = taskFinishedEvent(events); + + `@upstash/mcp-toolkit/tasks` doesn't import anything from Upstash. A backend implements one of these + interfaces (types exported from the same entry point): -export const tasks = createTaskLayer({ - store: new RedisTaskStore(), - dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal, - onSettle: taskFinished.onSettle, -}); -``` + ```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; // idempotent per task id + cancel(taskId: string): Promise; + createExecuteHandler(endpoints: TaskEndpoints): (request: Request) => Promise; + } + ``` + ## Why tools, not the Tasks extension? The 2026-07-28 MCP specification defines a [Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) -where the *client* polls `tasks/get`, so the model spends no turns waiting. It is the right long-term -shape, but a server may only return a task to a client that declares the extension, and the clients -people use don't yet. Plain tools work everywhere today. +where the *client* polls, so the model spends no turns waiting. 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 task record already has the extension's shape, so a native adapter can serve the same records -once clients support it. +The store and dispatcher don't depend on the tools, so an adapter for the extension can serve the +same records once clients support it. Also not implemented yet: `input_required` (a handler asking +the user something partway through a task) and listing tasks. ## Example @@ -88198,10 +88150,10 @@ Source: https://upstash.com/docs/redis/sdks/agentkit/mcp-tasks An MCP tool call is a request that waits for its response. When the work takes minutes, something gives up first: the client's tool timeout, your serverless function's limit, or the model. -`@upstash/mcp-toolkit/tasks` turns a slow tool into a task. The tool answers immediately with a task id, the -work runs on [QStash](/docs/qstash/overall/getstarted) or [Upstash Workflow](/docs/workflow/getstarted) so it -survives restarts and the client's tool timeout, and the task record lives in Upstash Redis. The model checks on it -with a shared `task_status` tool and can stop it with `task_cancel`. +`@upstash/mcp-toolkit/tasks` turns a slow tool into a task. The tool answers immediately with a task +id, the work runs on [QStash](/docs/qstash/overall/getstarted) or [Upstash Workflow](/docs/workflow/getstarted) +so it survives restarts and the client's tool timeout, and the task record lives in Upstash Redis. +The model checks on it with a shared `task_status` tool and can stop it with `task_cancel`. These are ordinary MCP tools, so they work in every client today: Claude Code, Codex, Cursor, OpenCode and ChatGPT. No client capability is required. The toolkit sits on top of the official MCP @@ -88213,70 +88165,80 @@ npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod ## Quickstart -Three files in a Next.js app. +Four files in a Next.js app. -### 1. Define the task layer, the task and the server +### 1. Identify the caller + +Every task belongs to the user who started it. `principal` returns that user's id, read from the +token your MCP route has verified, and throws 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; +} +``` + +See [Users](#users) for where `auth` comes from. + +### 2. Define the task layer, the task and the server ```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"; -// Where the task record lives, how the work is delivered, and who is calling. export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return userId; - }, + principal, }); -// Defined at module scope, so every instance knows the handler, including -// an /api/execute instance that never serves an MCP request. +// 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() }), - }, - // The handler: your long-running work. It runs later, in /api/execute, - // when QStash delivers the task — not inside the tool call. + { description: "Generates a report on a topic.", inputSchema: z.object({ topic: z.string() }) }, async ({ topic }, task) => { - await task.update("Researching sources"); - const report = await writeReport(topic); - return { content: [{ type: "text", text: report }] }; + await task.update("Reading sources"); // the model sees this when it polls + return { content: [{ type: "text", text: await writeReport(topic) }] }; }, ); -// Called per request. Adds generate_report, task_status and task_cancel. export function createServer() { const server = new McpServer({ name: "reports", version: "1.0.0" }); - tasks.register(server); + tasks.register(server); // adds generate_report, task_status and task_cancel return server; } ``` -`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. `principal` is -required, and `auth` is the verified token your route passes in; see [Users](#users). +The handler runs later, in `/api/execute`, when QStash delivers the task, not inside the tool call. +`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. -### 2. Serve the MCP endpoint +### 3. Serve the MCP endpoint ```ts app/api/mcp/route.ts -import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; import { createServer } from "../../../lib/tasks"; -const mcp = createMcpHandler(() => createServer()); +const handler = createMcpHandler(() => createServer()); -export const POST = (request: Request) => mcp.fetch(request); +export async function POST(request: Request) { + // Clerk, WorkOS, Auth0, your own. `principal` reads the user id from `authInfo.extra`. + const authInfo: AuthInfo | undefined = await verifyToken(request); + if (!authInfo) return new Response("Unauthorized", { status: 401 }); + return handler.fetch(request, { authInfo }); +} ``` -The task layer only registers tools, so this is the SDK's own handler, unchanged. It works the same -with [`mcp-handler`](https://www.npmjs.com/package/mcp-handler) or any transport. +The task layer only registers tools, so this is the SDK's own handler, unchanged. -### 3. Add the execute endpoint +### 4. Add the execute endpoint ```ts app/api/execute/route.ts import { tasks } from "../../../lib/tasks"; @@ -88284,14 +88246,8 @@ import { tasks } from "../../../lib/tasks"; export const POST = tasks.createExecuteHandler(); ``` -This is where QStash delivers each task. The handler it returns verifies the QStash signature, finds -the task's handler, runs it and stores the result. It answers `200` when done and `500` to ask QStash -for a retry. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`, since QStash -retries every other non-2xx. - -The signing keys are required. Without `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY` -(or a `receiver` you pass), the endpoint throws on its first request instead of running unverified -deliveries. The same applies to `WorkflowDispatcher`. +This is where QStash delivers each task. The handler verifies the QStash signature, runs the task's +handler and stores the result. ### Environment variables @@ -88299,6 +88255,7 @@ deliveries. The same applies to `WorkflowDispatcher`. UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... QSTASH_TOKEN=... +# Required: the execute endpoint refuses to run without them. QSTASH_CURRENT_SIGNING_KEY=... QSTASH_NEXT_SIGNING_KEY=... # Where QStash delivers tasks. Must be publicly reachable. @@ -88313,37 +88270,36 @@ APP_URL=https://your-app.vercel.app ## What the model sees -1. It calls `generate_report({ topic })` and gets back a `taskId`, `status: "working"`, and a line - telling it to call `task_status` in about two seconds. -2. `task_status({ taskId })` returns progress while the task runs, for example - `"Researching sources"`. -3. Once the task completes, `task_status` returns a one-line status followed by your handler's - `content`. +`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`. -`task_cancel({ taskId })` marks the task `cancelled` and stops any pending delivery. Each response -also carries the task object in `structuredContent`: + + ```jsonc + // generate_report: a handle, right away + { "content": [{ "type": "text", "text": "Started task 0e30…. Call task_status with taskId \"0e30…\" in about 2s to check on it." }], + "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 300000, "pollIntervalMs": 2000 } } -```json -{ - "taskId": "0e30…", - "status": "working", - "statusMessage": "Researching sources", - "pollIntervalMs": 2000, - "ttlMs": 300000 -} -``` + // 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" } } -A task is `working`, `completed`, `failed` or `cancelled`. The last three are final. + // …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 handler's `task` argument + 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. -| Member | What it does | -| --- | --- | -| `task.taskId` | The id of the running task. | -| `task.update(message)` | Writes a progress line. The model sees it on its next `task_status`. | -| `task.isCancelled()` | `true` once `task_cancel` was called, or the task expired. | + If the model stops polling, nothing is lost: the work finishes anyway, and the result can be read + until the task expires (5 minutes by default; set `defaults: { ttlMs }` on the layer). + + +## Cancellation -Cancellation is cooperative: your code keeps running until it checks. Check between steps: +`task_cancel` marks the task `cancelled` and stops any pending delivery. Cancelling is cooperative, +so running code only stops where it checks `task.isCancelled()`: ```ts async ({ sources }, task) => { @@ -88356,6 +88312,8 @@ async ({ sources }, task) => { }; ``` +`isCancelled()` also returns `true` once the task has expired. + Arguments are validated when the tool is called, then stored as JSON and handed to the handler later. Values that don't survive JSON, such as a `Date` produced by `z.coerce.date()`, arrive as @@ -88364,115 +88322,57 @@ async ({ sources }, task) => { ## Users -`principal` is required. It maps each call to a stable caller id, usually your user id. Each task -records who started it, and `task_status` / `task_cancel` only answer for that caller. Another -user's task id reads like an unknown one. - -`principal` receives `{ auth, request }` and may be async. +Each task records who started it, and `task_status` / `task_cancel` only answer for that caller. +Another user's task id reads exactly like an unknown one, so nobody can tell it exists. -### `auth`: the verified token +`principal` receives `{ auth, request }` and may be async. `auth` is the MCP SDK's `AuthInfo`. The SDK +never fills it in from request headers: your MCP route does, after verifying the bearer token, by +passing it to `handler.fetch(request, { authInfo })` as in step 3. Put the user id in `extra` when you +build `authInfo`, and read it back in `principal`. -`auth` is the MCP SDK's `AuthInfo`: + + * **A throw refuses the call.** There is no anonymous mode: if `principal` throws, rejects, or + returns anything but a non-empty string, the tool 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. + * `principal` also receives `request`, for cookie or session apps. It is unverified, so check the + session yourself, and never trust a header like `x-user-id`: -```ts -interface AuthInfo { - token: string; - clientId: string; // the OAuth app, e.g. one id for every ChatGPT user - scopes: string[]; - expiresAt?: number; - extra?: Record; // free-form, filled by your verifier -} -``` - -The SDK never fills it in from request headers. Your MCP route does, after verifying the bearer -token with your OAuth provider, by passing it to the handler: - -```ts app/api/mcp/route.ts -import { createMcpHandler } from "@modelcontextprotocol/server"; -import { createServer } from "../../../lib/tasks"; - -const mcp = createMcpHandler(() => createServer()); - -export async function POST(request: Request) { - // Verify the bearer token with Clerk, WorkOS, Auth0 or your own auth server. - const authInfo = await verifyToken(request); - if (!authInfo) return new Response("Unauthorized", { status: 401 }); - return mcp.fetch(request, { authInfo }); -} -``` - -Put the user id in `extra` when you build `authInfo`, and read it back in `principal`, as in the -quickstart: - -```ts -principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return userId; -}, -``` - -Because the token was already checked, this is the path to prefer. Key on the user, not -`auth.clientId`: the client id identifies the OAuth app, which can be one id shared by every user of -a host like ChatGPT. - -### `request`: the raw HTTP request - -Apps that authenticate with a cookie or a session, rather than building an `AuthInfo`, can read the -request instead: - -```ts -principal: async ({ request }) => { - const session = await getSession(request); - if (!session) throw new Error("Not authenticated"); - return session.userId; -}, -``` - -The request is unverified. Check the session yourself, and never trust a header like `x-user-id` -that the caller can set. - -### No anonymous mode + ```ts + import type { Caller } from "@upstash/mcp-toolkit/tasks"; -`principal` must return an id; its type doesn't allow `undefined`. When it can't identify the -caller, it throws, and the tool call is refused with "Not authenticated". The toolkit fails closed -even if the types are bypassed: an answer without a non-empty string id is refused too. A server -with no users of its own passes `principal: () => "local"`. + export async function principal({ request }: Caller): Promise { + const session = await getSession(request); + if (!session) throw new Error("Not authenticated"); + return session.userId; + } + ``` - - Task arguments and results are stored in Redis as plain JSON for the task's `ttlMs`, which is - forever with `ttlMs: null`. Keep secrets out of them, or use a short TTL. - + * A server with no users of its own passes `principal: () => "local"`. + ## Work longer than one function invocation -A QStash delivery is one serverless invocation, so the handler has to finish within your platform's -function limit. If it is killed, QStash redelivers and the handler starts over. +With `QStashDispatcher`, 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. -For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, and finished steps -are replayed from a journal instead of re-run. The handler's `task` then also has the Workflow API -(`task.run`, `task.sleep`, `task.call`), inferred from the dispatcher with no extra typing: +`WorkflowDispatcher` 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. The +handler's `task` then also has `run`, `sleep` and `call`, inferred from the dispatcher: ```ts lib/tasks.ts -import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/upstash"; -import * as z from "zod"; export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal, // the same function as in the quickstart - // A TTL longer than the work. It runs from creation and is never extended; once it passes, - // the record is gone and isCancelled() returns true. The default is 5 minutes. - defaults: { ttlMs: 60 * 60 * 1000 }, + principal, + defaults: { ttlMs: 60 * 60 * 1000 }, // keep the record longer than the work takes }); tasks.define( "migrate_workspace", - { - description: "Copies a workspace's documents to the new storage, one batch at a time.", - inputSchema: z.object({ workspaceId: z.string() }), - }, + { description: "Copies a workspace to new storage.", inputSchema: z.object({ workspaceId: z.string() }) }, async ({ workspaceId }, task) => { const batches = await task.run("plan", () => listBatches(workspaceId)); for (const [i, batch] of batches.entries()) { @@ -88480,68 +88380,132 @@ tasks.define( await task.update(`Copying batch ${i + 1}/${batches.length}`); await task.run(`copy-${i}`, () => copyBatch(batch)); } - await task.sleep("settle", 30); // waits without holding a function open - const report = await task.run("verify", () => verifyCopy(workspaceId)); - return { content: [{ type: "text", text: report }] }; + return { content: [{ type: "text", text: "Done" }] }; }, ); ``` -Put side effects inside `task.run`. Code outside a step runs again on every invocation, and each -step is still one invocation, so keep every step within your function limit. + + The handler runs again from the top on every step, and finished steps are replayed from the + journal: -| | `QStashDispatcher` | `WorkflowDispatcher` | -| --- | --- | --- | -| Survives a crash | Yes, redelivered | Yes, replayed | -| Outlives one invocation | No | Yes, one invocation per step | -| On retry | Starts the handler over | Resumes from the last finished step | + * 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, so set `ttlMs` longer than the work takes. + + | | `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: true` 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. + This holds for `WorkflowDispatcher` too. Without signing keys (or a `receiver` you pass), the + route throws on its first request 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. + + +## Security and storage + + + The server sets a task's owner from `principal`, and no tool argument can set it. `task_status` and + `task_cancel` only accept UUID task ids, and compare the stored owner with the caller. Task ids are + random UUIDs. No tool lists tasks, and the execute route only accepts signed QStash deliveries and + never returns a task. + + Anyone with write access to your Redis can change an owner, so treat the Redis credentials like any + other production secret. + + + + **`mcp:task:`** is 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. + + A task message in QStash carries only `{ taskId }`. The toolkit never stores the caller's token or + the request. + ## Options -**`createTaskLayer`** + + **`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: + `defaults.ttlMs` (5 minutes) and `defaults.pollIntervalMs` (2 seconds). -| Option | Default | | -| --- | --- | --- | -| `store` | required | `RedisTaskStore`, or your own `TaskStore` | -| `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | -| `principal` | required | `({ auth, request }) => string`, may be async. The caller's id; throw to refuse the call. | -| `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | -| `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | -| `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | -| `onSettle` | none | `(task) => void`. Called once when a task completes, fails or is cancelled. | + **`tasks.define(name, config, handler)`**: `description` and `inputSchema` are required. Optional: + `title`, `completedMessage` (defaults to `"Completed"`). -**`tasks.define(name, config, handler)` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, -`pollIntervalMs`, `queuedMessage` and `completedMessage`. + **`RedisTaskStore`**: `redis` (defaults to one from env), `prefix` (`mcp:task:`), `enableTelemetry`. -## Push instead of poll + **`QStashDispatcher`**: `url` is required. Optional: `qstash`, `receiver`, `retries` (5), + `retryDelay`, `enableTelemetry`. -A host that supports MCP Events can get a webhook when a task finishes instead of polling. Wire the -`task.finished` event from [MCP Events](/docs/redis/sdks/agentkit/mcp-events) into `onSettle`; it fires to -the task's owner only: + **`WorkflowDispatcher`**: `url` is required. Optional: `client`, `qstash`, `receiver`, `retries`, + `enableTelemetry`. -```ts lib/tasks.ts -import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; -import { events } from "./events"; // your event layer + `receiver` defaults to one built from the `QSTASH_*_SIGNING_KEY` variables. 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`. + -const taskFinished = taskFinishedEvent(events); + + `@upstash/mcp-toolkit/tasks` doesn't import anything from Upstash. A backend implements one of these + interfaces (types exported from the same entry point): -export const tasks = createTaskLayer({ - store: new RedisTaskStore(), - dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal, - onSettle: taskFinished.onSettle, -}); -``` + ```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; // idempotent per task id + cancel(taskId: string): Promise; + createExecuteHandler(endpoints: TaskEndpoints): (request: Request) => Promise; + } + ``` + ## Why tools, not the Tasks extension? The 2026-07-28 MCP specification defines a [Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) -where the *client* polls `tasks/get`, so the model spends no turns waiting. It is the right long-term -shape, but a server may only return a task to a client that declares the extension, and the clients -people use don't yet. Plain tools work everywhere today. +where the *client* polls, so the model spends no turns waiting. 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 task record already has the extension's shape, so a native adapter can serve the same records -once clients support it. +The store and dispatcher don't depend on the tools, so an adapter for the extension can serve the +same records once clients support it. Also not implemented yet: `input_required` (a handler asking +the user something partway through a task) and listing tasks. ## Example diff --git a/redis/sdks/agentkit/mcp-events.mdx b/redis/sdks/agentkit/mcp-events.mdx index 38f0009e6..4b1dc1aae 100644 --- a/redis/sdks/agentkit/mcp-events.mdx +++ b/redis/sdks/agentkit/mcp-events.mdx @@ -14,9 +14,10 @@ signing and retries: subscriptions live in Upstash Redis, and every delivery goe [QStash](/qstash/overall/getstarted), which retries failed webhooks with backoff. - MCP Events is a draft. As of October 2026, ChatGPT is the host that subscribes to it (webhook - delivery, in Work chats), following [OpenAI's MCP Events guide](https://developers.openai.com/plugins/build/mcp-events). - Claude Code, Cursor and OpenCode don't subscribe yet. + MCP Events is a draft. As of October 2026, ChatGPT is the only widely used host that subscribes to + it (webhook delivery, in Work chats), following [OpenAI's MCP Events 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. ```bash @@ -25,42 +26,51 @@ npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod ## Quickstart -Three files in a Next.js app. +Four files in a Next.js app. -### 1. Define the event layer and your events +### 1. Identify the caller + +Every subscription belongs to the user who subscribed. `principal` returns that user's id, read from +the token your MCP route has verified, and throws when there is none. If you also use +[MCP Tasks](/redis/sdks/agentkit/mcp-tasks), both layers share this function: + +```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; +} +``` + +### 2. Define the event layer and your 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` }), - // Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 - secretKey: process.env.MCP_EVENTS_SECRET_KEY, - // Who is calling. `auth` is the verified token your MCP route passes to the SDK; see - // "Users" in the MCP Tasks docs for how it's populated. - principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return userId; - }, + principal, }); export const commentCreated = events.define("comment.created", { - description: "A new review comment was added to a document.", - // What a subscriber filters on. - input: z.object({ documentId: z.string() }), - // What every delivery carries. - payload: z.object({ documentId: z.string(), commentId: z.string(), text: z.string() }), - // Required: who may subscribe with these arguments and receive what they match. - // Runs on every subscribe and refresh, and again before every delivery. + description: "A new comment was added to a document.", + input: z.object({ documentId: z.string().optional() }), // what a subscriber may filter on + payload: z.object({ documentId: z.string(), text: z.string() }), + // May this user see comments on this document? authorize: (args, { principal }) => canRead(principal, args.documentId), }); ``` -### 2. Register the events on your MCP server +`authorize` is required. See [Matching and authorize](#matching-and-authorize) for when it runs. + +### 3. Register the events on your MCP server ```ts lib/server.ts import { McpServer } from "@modelcontextprotocol/server"; @@ -68,16 +78,29 @@ import { events } from "./events"; export function createServer() { const server = new McpServer({ name: "docs", version: "1.0.0" }); - // Adds events/list, events/subscribe and events/unsubscribe next to your tools. - events.register(server); + events.register(server); // adds events/list, events/subscribe and events/unsubscribe return server; } ``` -Serve it with `createMcpHandler(() => createServer())` as usual. Nothing else changes on the MCP -endpoint. +Serve it from your MCP route with the verified token, as in the +[MCP Tasks quickstart](/redis/sdks/agentkit/mcp-tasks#quickstart): -### 3. Add the delivery endpoint +```ts app/api/mcp/route.ts +import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; +import { createServer } from "../../../lib/server"; + +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 }); +} +``` + +### 4. Add the delivery endpoint ```ts app/api/events/route.ts import { events } from "../../../lib/events"; @@ -85,21 +108,20 @@ import { events } from "../../../lib/events"; export const POST = events.createDeliveryHandler(); ``` -QStash delivers each event here. The handler verifies the QStash signature, signs the envelope -with the subscriber's secret, POSTs it to the host, and answers `500` when the host's callback -failed so QStash retries. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`. +QStash delivers each event here. The handler verifies the QStash signature, signs the envelope with +the subscriber's secret and POSTs it to the host. ### Emit Call `emit` wherever the change happens: a route, a webhook from your own app, a background job. ```ts -await commentCreated.emit({ documentId: "doc_123", commentId: "c_9", text: "Ship it?" }); +await commentCreated.emit({ documentId: "doc_123", text: "Ship it?" }); ``` -`emit` is typed by the `payload` schema and validates against it. It reaches every subscription -whose arguments match (everyone watching `doc_123`), checks `authorize` again for each subscriber -before delivering, and returns the event id and how many subscriptions matched. +`emit` is typed by the `payload` schema and validates against it. Every subscription that matches +the payload, and that `authorize` still allows for `documentId: "doc_123"`, gets a signed webhook. +It returns `{ eventId }`. ### Environment variables @@ -107,6 +129,7 @@ before delivering, and returns the event id and how many subscriptions matched. UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... QSTASH_TOKEN=... +# Required: the delivery endpoint refuses to run without them. QSTASH_CURRENT_SIGNING_KEY=... QSTASH_NEXT_SIGNING_KEY=... # Encrypts the hosts' signing secrets at rest. Generate with: openssl rand -base64 32 @@ -121,205 +144,152 @@ APP_URL=https://your-app.vercel.app `whsec_` signing secret. 2. For a new callback or secret, your server POSTs a signed verification challenge and requires it echoed back, then stores the subscription and answers with its `id` and a `refreshBefore` time. -3. When you `emit`, every matching subscription that `authorize` still allows gets a POST with the event envelope and - [Standard Webhooks](https://www.standardwebhooks.com/) headers: +3. When you `emit`, every matching subscription that `authorize` still allows gets a POST with the + event envelope and [Standard Webhooks](https://www.standardwebhooks.com/) headers: ```json { "eventId": "evt_7c1…", "name": "comment.created", "timestamp": "2026-10-07T12:05:00Z", - "data": { "documentId": "doc_123", "commentId": "c_9", "text": "Ship it?" }, + "data": { "documentId": "doc_123", "text": "Ship it?" }, "cursor": null } ``` -The event id stays the same across retries, so the host can drop duplicates. The signature is -computed fresh on every attempt. +QStash retries failed deliveries with backoff. The event id stays the same across retries, so the +host can drop duplicates, and the signature is computed fresh on every attempt. -## Matching +## Matching and authorize -A subscription matches when every argument it gave equals the emitted value. Emitting -`{ repo: "a", branch: "main" }` reaches subscribers of `{ repo: "a" }`, of -`{ repo: "a", branch: "main" }`, and of `{}`. +Every `input` field must also be a `payload` field: the payload is the one place an event's values +come from, and the same values are used to route it and to authorize it. A subscription matches when +each argument it gave equals the payload's value. Emitting `{ documentId: "doc_123", text }` reaches +subscribers of `{ documentId: "doc_123" }` and of `{}`. -The values come from the payload fields named in the input schema, so when the payload carries -every input field, `emit(payload)` needs nothing else. When it doesn't, the type of `emit` makes -`args` required, and an emit with no value for a required input field throws, so a filter can never -silently match nothing: +`authorize(args, caller)` runs twice: -```ts -const replyPosted = events.define("reply.posted", { - description: "A reply was posted in a thread.", - input: z.object({ threadId: z.string() }), - payload: z.object({ text: z.string() }), // no threadId here - authorize: (args, { principal }) => canReadThread(principal, args.threadId), -}); +- **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. -await replyPosted.emit({ text: "Agreed" }, { args: { threadId } }); // `args` is required -``` - -Other options: - -```ts -await commentCreated.emit(payload, { - to: ["alice", "carol"], // only these users' subscriptions; optional - eventId: comment.id, // emitting the same id twice is deduplicated -}); -``` - -For conditions that exact matching can't express, add `match: (args, payload) => boolean` to the -event definition. +The token is never stored, so the delivery-time check gets no `auth` or `request`. +`authorize: () => true` lets every authenticated subscriber hear every matching event. -## Users and subscriptions + + Pass `{ eventId }` as the second argument to `emit`. Emitting the same id twice delivers once: -Three things decide who gets an event: the callback URL says **where** it goes, the subscription's -arguments say **what**, and `authorize` says **who may**. + ```ts + await commentCreated.emit({ documentId, text }, { eventId: comment.id }); + ``` + -**The host routes to its user.** Every subscription carries a callback URL and signing secret that -the host generated for it. ChatGPT, for example, sends a unique -`https://connectors.api.openai.com/webhook/mcp-events/` per monitor. Posting to that URL -reaches the right user's agent, so your server never needs to know who the host user is. Two users -subscribing to the same event produce two subscriptions and two deliveries. + + **The host routes to its user.** Every subscription carries a callback URL and signing secret that + the host generated for it. ChatGPT, for example, sends a unique + `https://connectors.api.openai.com/webhook/mcp-events/` per monitor. Posting to that URL + reaches the right user's agent, so your server never needs to know who the host user is. Two users + subscribing to the same event produce two subscriptions and two deliveries. -**Your server knows who subscribed.** `events/subscribe` arrives with the same auth as any other MCP -request: the `AuthInfo` your route verified and passed to `handler.fetch(request, { authInfo })` -(see [Users](/redis/sdks/agentkit/mcp-tasks#users)). `principal({ auth, request })` turns it into -the subscriber's id, which is stored on the subscription and becomes part of its id. + **Your server knows who subscribed.** `events/subscribe` arrives with the same auth as any other + MCP request, and `principal` turns it into the subscriber's id. `events/subscribe` and + `events/unsubscribe` resolve `principal` before anything else, so an unidentified caller learns + nothing about your events and is refused with reason `not_authenticated`. A subscription's id is a + hash of subscriber, callback URL, event and arguments, so a user can only refresh or remove their + own. A server with no users of its own passes `principal: () => "local"`. -**`authorize` decides who may, and keeps deciding.** It is required, and runs twice: - -| When | What it gets | -| --- | --- | -| Every subscribe and refresh | `args`, `{ principal, context, phase: "subscribe", auth, request }` | -| Before every delivery | `args`, `{ principal, context, phase: "deliver" }` | - -The token is never stored, so there is no `auth` or `request` behind a delivery. The second check is -what stops events when someone loses access after subscribing; a delivery it refuses is dropped, not -retried. An event any authenticated caller may hear says so with `authorize: () => true`. - -**Context for the delivery check.** Return `{ id, context }` from `principal` instead of a string -to store small, non-secret data (an org id, a role) with the subscription. `authorize` gets it back -as `context`: - -```ts -const events = createEventLayer({ - store, - delivery, - principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return { id: userId, context: { orgId: auth?.extra?.orgId } }; // never the token - }, -}); - -const memoPosted = events.define("memo.posted", { - description: "A memo was posted to your org.", - input: z.object({ orgId: z.string() }), - payload: z.object({ orgId: z.string(), text: z.string() }), - authorize: (args, { context }) => context.orgId === args.orgId, -}); -``` - -`context` is a snapshot from subscribe time, capped at 4 KB. For revocation, check your own data -with `principal` rather than trusting the snapshot. - -**Personal events use `to`.** Some events belong to one user, like "your export finished". Mark them -`personal: true`, and every `emit` must name the user with `to`; the type requires it, and so does -a runtime check. A subscription with no arguments then only hears about its own user's events: - -```ts -const exportFinished = events.define("export.finished", { - description: "Your export finished.", - payload: z.object({ exportId: z.string(), url: z.string() }), - personal: true, - authorize: () => true, // `to` already limits it to the export's owner -}); - -await exportFinished.emit({ exportId, url }, { to: userId }); -``` - -Unsubscribing recomputes the id from the caller's principal, so only the subscriber can remove a -subscription. - - - `principal` is required, and there are no anonymous subscriptions: when it throws, - subscribe and unsubscribe are refused with reason `not_authenticated`, before any challenge is - sent. A server with no users of its own passes `principal: () => "local"`. - - -## Task finished events - -If you use [MCP Tasks](/redis/sdks/agentkit/mcp-tasks), a `task.finished` event lets an -event-capable host stop polling `task_status`: - -```ts -import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; - -const taskFinished = taskFinishedEvent(events); - -export const tasks = createTaskLayer({ store, dispatcher, principal, onSettle: taskFinished.onSettle }); -``` - -It fires once when a task completes, fails or is cancelled. It is a personal event, emitted `to` the -task's owner, so only their subscriptions get it. We haven't tested it with ChatGPT yet. A host subscribes with no arguments for every task, or with `taskId` for one. The -payload includes the result when it fits in the 256 KiB envelope. + Every recipient of an emit gets the same payload, so don't put data in it that only some matching + subscribers may see. + ## Security -- **Callback URLs** must be `https` on a public host. `localhost`, `.local` and `.internal` names - (trailing dots included), private, link-local, reserved and documentation IPs (also when embedded - in IPv6), and credentials in the URL are refused, and redirects are never followed. The checks don't - resolve DNS, so add egress filtering in production. -- **Verification:** a subscription is stored only after the callback echoes a signed challenge. - Every failure returns the same error `-32015`, so a subscriber can't probe your network; the - detail goes to your server logs. -- **Secrets** are stored encrypted with `MCP_EVENTS_SECRET_KEY`. A refresh with the same secret - skips the challenge; a new secret verifies again. -- **Authorization:** `authorize` runs on every subscribe and refresh, and again before every - delivery. -- **Delivery endpoint:** verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` variables (or - a `receiver` you pass). Without them it throws on the first request; it never sends unverified - deliveries. + + - **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. + - **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. + - **The delivery endpoint.** It verifies QStash's signature with the `QSTASH_*_SIGNING_KEY` + variables (or a `receiver` you pass). Without them it throws on the first request instead of + sending unverified deliveries. A bad signature or body gets `489` with + `Upstash-NonRetryable-Error: true`. + - **Lifetime.** A subscription lasts 7 days by default and 30 days at most. + - `allowInsecureCallbacks: true` accepts `http://` and private addresses. Use it only for local + `allowInsecureCallbacks: true` turns the callback URL checks off. Use it only for local development, never in production. -## Host responses - -| Callback answers | What happens | -| --- | --- | -| `2xx` | Delivered. | -| `410 Gone` | The subscription is deleted. | -| `413`, or a redirect | The event is dropped, not retried. | -| Anything else, or no answer | QStash retries with backoff. | + + | Callback answers | What happens | + | --- | --- | + | `2xx` | Delivered. | + | `410 Gone` | The subscription is deleted. | + | `413`, or a redirect | The event is dropped, not retried. | + | Anything else, or no answer | QStash retries with backoff. | + + + + **`mcp-events:sub:`** is the subscription (`event`, `args`, `url`, `encryptedSecret`, + `subscriber`, `createdAt`, `expiresAt`), expiring with it. **`mcp-events:idx:`** is a sorted + set of the event's subscription ids, scored by expiry. + + 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`. Anyone with write access to your Redis can + change a callback URL, so treat the Redis credentials like any other production secret. + ## Options -**`createEventLayer`** + + **`createEventLayer`**: `store`, `delivery` and `principal` are required, and so is `secretKey` + unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `allowInsecureCallbacks`. + + **`events.define(name, config)`**: `description`, `payload` and `authorize` are required. + Optional: `title`, `input`. + + **`RedisSubscriptionStore`**: `redis` (defaults to one from env), `prefix` (`mcp-events:`), + `enableTelemetry`. + + **`QStashDelivery`**: `url` is required. Optional: `qstash`, `receiver`, `enableTelemetry`. + Deliveries are retried 3 times with QStash's backoff. + -| Option | Default | | -| --- | --- | --- | -| `store` | required | `RedisSubscriptionStore`, or your own `SubscriptionStore` | -| `delivery` | required | `QStashDelivery`, or your own `EventDelivery` | -| `principal` | required | `({ auth, request }) => id \| { id, context }`, may be async. The subscriber; throw to refuse. | -| `secretKey` | `MCP_EVENTS_SECRET_KEY` | Encrypts stored signing secrets. Required, with no built-in default. | -| `defaults.ttlMs` | 7 days | Lifetime granted when the host doesn't ask for one | -| `defaults.maxTtlMs` | 30 days | The most the server grants | -| `allowInsecureCallbacks` | `false` | Local development only | -| `timeoutMs` | 10 seconds | Timeout for each POST to a callback | + + `@upstash/mcp-toolkit/events` doesn't import anything from Upstash. A backend implements one of + these interfaces (types exported from the same entry point): -**`define` config:** `description`, `payload` and `authorize` (required), plus optional `title`, -`input`, `personal` and `match`. + ```ts + interface SubscriptionStore { + put(subscription: Subscription): Promise; + get(id: string): Promise; + delete(subscription: { id: string; event: string }): Promise; + find(event: string): Promise; // every live subscription to the event + } -**`QStashDelivery`:** `url` (required), `retries` (default 3), `retryDelay`, `headers`, `qstash`, -`receiver`. + interface EventDelivery { + enqueue(jobs: DeliveryJob[]): Promise; + createDeliveryHandler(send: SendJob): (request: Request) => Promise; + } + ``` -**`RedisSubscriptionStore`:** `redis`, `prefix` (default `mcp-events:`). + The entry point also exports `verifyWebhook` (Standard Webhooks) for writing a receiver. + -Poll and stream delivery modes and replay cursors from the draft aren't implemented. +Not implemented: the draft's poll and stream delivery modes (`events/subscribe` refuses them) and +event replay (`cursor` is always `null`). ## Example diff --git a/redis/sdks/agentkit/mcp-tasks.mdx b/redis/sdks/agentkit/mcp-tasks.mdx index c14597edf..e6f5ad4df 100644 --- a/redis/sdks/agentkit/mcp-tasks.mdx +++ b/redis/sdks/agentkit/mcp-tasks.mdx @@ -7,10 +7,10 @@ description: "Durable long-running tools for MCP servers: a tool answers with a An MCP tool call is a request that waits for its response. When the work takes minutes, something gives up first: the client's tool timeout, your serverless function's limit, or the model. -`@upstash/mcp-toolkit/tasks` turns a slow tool into a task. The tool answers immediately with a task id, the -work runs on [QStash](/qstash/overall/getstarted) or [Upstash Workflow](/workflow/getstarted) so it -survives restarts and the client's tool timeout, and the task record lives in Upstash Redis. The model checks on it -with a shared `task_status` tool and can stop it with `task_cancel`. +`@upstash/mcp-toolkit/tasks` turns a slow tool into a task. The tool answers immediately with a task +id, the work runs on [QStash](/qstash/overall/getstarted) or [Upstash Workflow](/workflow/getstarted) +so it survives restarts and the client's tool timeout, and the task record lives in Upstash Redis. +The model checks on it with a shared `task_status` tool and can stop it with `task_cancel`. These are ordinary MCP tools, so they work in every client today: Claude Code, Codex, Cursor, OpenCode and ChatGPT. No client capability is required. The toolkit sits on top of the official MCP @@ -22,70 +22,80 @@ npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod ## Quickstart -Three files in a Next.js app. +Four files in a Next.js app. -### 1. Define the task layer, the task and the server +### 1. Identify the caller + +Every task belongs to the user who started it. `principal` returns that user's id, read from the +token your MCP route has verified, and throws 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; +} +``` + +See [Users](#users) for where `auth` comes from. + +### 2. Define the task layer, the task and the server ```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"; -// Where the task record lives, how the work is delivered, and who is calling. export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return userId; - }, + principal, }); -// Defined at module scope, so every instance knows the handler, including -// an /api/execute instance that never serves an MCP request. +// 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() }), - }, - // The handler: your long-running work. It runs later, in /api/execute, - // when QStash delivers the task — not inside the tool call. + { description: "Generates a report on a topic.", inputSchema: z.object({ topic: z.string() }) }, async ({ topic }, task) => { - await task.update("Researching sources"); - const report = await writeReport(topic); - return { content: [{ type: "text", text: report }] }; + await task.update("Reading sources"); // the model sees this when it polls + return { content: [{ type: "text", text: await writeReport(topic) }] }; }, ); -// Called per request. Adds generate_report, task_status and task_cancel. export function createServer() { const server = new McpServer({ name: "reports", version: "1.0.0" }); - tasks.register(server); + tasks.register(server); // adds generate_report, task_status and task_cancel return server; } ``` -`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. `principal` is -required, and `auth` is the verified token your route passes in; see [Users](#users). +The handler runs later, in `/api/execute`, when QStash delivers the task, not inside the tool call. +`topic` is typed from `inputSchema`: Zod, Valibot and ArkType schemas all work. -### 2. Serve the MCP endpoint +### 3. Serve the MCP endpoint ```ts app/api/mcp/route.ts -import { createMcpHandler } from "@modelcontextprotocol/server"; +import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; import { createServer } from "../../../lib/tasks"; -const mcp = createMcpHandler(() => createServer()); +const handler = createMcpHandler(() => createServer()); -export const POST = (request: Request) => mcp.fetch(request); +export async function POST(request: Request) { + // Clerk, WorkOS, Auth0, your own. `principal` reads the user id from `authInfo.extra`. + const authInfo: AuthInfo | undefined = await verifyToken(request); + if (!authInfo) return new Response("Unauthorized", { status: 401 }); + return handler.fetch(request, { authInfo }); +} ``` -The task layer only registers tools, so this is the SDK's own handler, unchanged. It works the same -with [`mcp-handler`](https://www.npmjs.com/package/mcp-handler) or any transport. +The task layer only registers tools, so this is the SDK's own handler, unchanged. -### 3. Add the execute endpoint +### 4. Add the execute endpoint ```ts app/api/execute/route.ts import { tasks } from "../../../lib/tasks"; @@ -93,14 +103,8 @@ import { tasks } from "../../../lib/tasks"; export const POST = tasks.createExecuteHandler(); ``` -This is where QStash delivers each task. The handler it returns verifies the QStash signature, finds -the task's handler, runs it and stores the result. It answers `200` when done and `500` to ask QStash -for a retry. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`, since QStash -retries every other non-2xx. - -The signing keys are required. Without `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY` -(or a `receiver` you pass), the endpoint throws on its first request instead of running unverified -deliveries. The same applies to `WorkflowDispatcher`. +This is where QStash delivers each task. The handler verifies the QStash signature, runs the task's +handler and stores the result. ### Environment variables @@ -108,6 +112,7 @@ deliveries. The same applies to `WorkflowDispatcher`. UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... QSTASH_TOKEN=... +# Required: the execute endpoint refuses to run without them. QSTASH_CURRENT_SIGNING_KEY=... QSTASH_NEXT_SIGNING_KEY=... # Where QStash delivers tasks. Must be publicly reachable. @@ -122,37 +127,36 @@ APP_URL=https://your-app.vercel.app ## What the model sees -1. It calls `generate_report({ topic })` and gets back a `taskId`, `status: "working"`, and a line - telling it to call `task_status` in about two seconds. -2. `task_status({ taskId })` returns progress while the task runs, for example - `"Researching sources"`. -3. Once the task completes, `task_status` returns a one-line status followed by your handler's - `content`. - -`task_cancel({ taskId })` marks the task `cancelled` and stops any pending delivery. Each response -also carries the task object in `structuredContent`: - -```json -{ - "taskId": "0e30…", - "status": "working", - "statusMessage": "Researching sources", - "pollIntervalMs": 2000, - "ttlMs": 300000 -} -``` +`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`. -A task is `working`, `completed`, `failed` or `cancelled`. The last three are final. + + ```jsonc + // generate_report: a handle, right away + { "content": [{ "type": "text", "text": "Started task 0e30…. Call task_status with taskId \"0e30…\" in about 2s to check on it." }], + "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 300000, "pollIntervalMs": 2000 } } -## The handler's `task` argument + // 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" } } -| Member | What it does | -| --- | --- | -| `task.taskId` | The id of the running task. | -| `task.update(message)` | Writes a progress line. The model sees it on its next `task_status`. | -| `task.isCancelled()` | `true` once `task_cancel` was called, or the task expired. | + // …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": [ … ] } } } + ``` -Cancellation is cooperative: your code keeps running until it checks. Check between steps: + 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 (5 minutes by default; set `defaults: { ttlMs }` on the layer). + + +## Cancellation + +`task_cancel` marks the task `cancelled` and stops any pending delivery. Cancelling is cooperative, +so running code only stops where it checks `task.isCancelled()`: ```ts async ({ sources }, task) => { @@ -165,6 +169,8 @@ async ({ sources }, task) => { }; ``` +`isCancelled()` also returns `true` once the task has expired. + Arguments are validated when the tool is called, then stored as JSON and handed to the handler later. Values that don't survive JSON, such as a `Date` produced by `z.coerce.date()`, arrive as @@ -173,115 +179,57 @@ async ({ sources }, task) => { ## Users -`principal` is required. It maps each call to a stable caller id, usually your user id. Each task -records who started it, and `task_status` / `task_cancel` only answer for that caller. Another -user's task id reads like an unknown one. - -`principal` receives `{ auth, request }` and may be async. - -### `auth`: the verified token - -`auth` is the MCP SDK's `AuthInfo`: - -```ts -interface AuthInfo { - token: string; - clientId: string; // the OAuth app, e.g. one id for every ChatGPT user - scopes: string[]; - expiresAt?: number; - extra?: Record; // free-form, filled by your verifier -} -``` - -The SDK never fills it in from request headers. Your MCP route does, after verifying the bearer -token with your OAuth provider, by passing it to the handler: - -```ts app/api/mcp/route.ts -import { createMcpHandler } from "@modelcontextprotocol/server"; -import { createServer } from "../../../lib/tasks"; - -const mcp = createMcpHandler(() => createServer()); - -export async function POST(request: Request) { - // Verify the bearer token with Clerk, WorkOS, Auth0 or your own auth server. - const authInfo = await verifyToken(request); - if (!authInfo) return new Response("Unauthorized", { status: 401 }); - return mcp.fetch(request, { authInfo }); -} -``` - -Put the user id in `extra` when you build `authInfo`, and read it back in `principal`, as in the -quickstart: - -```ts -principal: ({ auth }) => { - const userId = auth?.extra?.userId; - if (typeof userId !== "string") throw new Error("Not authenticated"); - return userId; -}, -``` - -Because the token was already checked, this is the path to prefer. Key on the user, not -`auth.clientId`: the client id identifies the OAuth app, which can be one id shared by every user of -a host like ChatGPT. - -### `request`: the raw HTTP request - -Apps that authenticate with a cookie or a session, rather than building an `AuthInfo`, can read the -request instead: - -```ts -principal: async ({ request }) => { - const session = await getSession(request); - if (!session) throw new Error("Not authenticated"); - return session.userId; -}, -``` - -The request is unverified. Check the session yourself, and never trust a header like `x-user-id` -that the caller can set. - -### No anonymous mode - -`principal` must return an id; its type doesn't allow `undefined`. When it can't identify the -caller, it throws, and the tool call is refused with "Not authenticated". The toolkit fails closed -even if the types are bypassed: an answer without a non-empty string id is refused too. A server -with no users of its own passes `principal: () => "local"`. +Each task records who started it, and `task_status` / `task_cancel` only answer for that caller. +Another user's task id reads exactly like an unknown one, so nobody can tell it exists. + +`principal` receives `{ auth, request }` and may be async. `auth` is the MCP SDK's `AuthInfo`. The SDK +never fills it in from request headers: your MCP route does, after verifying the bearer token, by +passing it to `handler.fetch(request, { authInfo })` as in step 3. Put the user id in `extra` when you +build `authInfo`, and read it back in `principal`. + + + - **A throw refuses the call.** There is no anonymous mode: if `principal` throws, rejects, or + returns anything but a non-empty string, the tool 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. + - `principal` also receives `request`, for cookie or session apps. It is unverified, so check the + session yourself, and never trust a header like `x-user-id`: + + ```ts + import type { Caller } from "@upstash/mcp-toolkit/tasks"; + + export async function principal({ request }: Caller): Promise { + const session = await getSession(request); + if (!session) throw new Error("Not authenticated"); + return session.userId; + } + ``` - - Task arguments and results are stored in Redis as plain JSON for the task's `ttlMs`, which is - forever with `ttlMs: null`. Keep secrets out of them, or use a short TTL. - + - A server with no users of its own passes `principal: () => "local"`. + ## Work longer than one function invocation -A QStash delivery is one serverless invocation, so the handler has to finish within your platform's -function limit. If it is killed, QStash redelivers and the handler starts over. +With `QStashDispatcher`, 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. -For longer work, use `WorkflowDispatcher`. Each step runs as its own invocation, and finished steps -are replayed from a journal instead of re-run. The handler's `task` then also has the Workflow API -(`task.run`, `task.sleep`, `task.call`), inferred from the dispatcher with no extra typing: +`WorkflowDispatcher` 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. The +handler's `task` then also has `run`, `sleep` and `call`, inferred from the dispatcher: ```ts lib/tasks.ts -import { createTaskLayer } from "@upstash/mcp-toolkit/tasks"; import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-toolkit/upstash"; -import * as z from "zod"; export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal, // the same function as in the quickstart - // A TTL longer than the work. It runs from creation and is never extended; once it passes, - // the record is gone and isCancelled() returns true. The default is 5 minutes. - defaults: { ttlMs: 60 * 60 * 1000 }, + principal, + defaults: { ttlMs: 60 * 60 * 1000 }, // keep the record longer than the work takes }); tasks.define( "migrate_workspace", - { - description: "Copies a workspace's documents to the new storage, one batch at a time.", - inputSchema: z.object({ workspaceId: z.string() }), - }, + { description: "Copies a workspace to new storage.", inputSchema: z.object({ workspaceId: z.string() }) }, async ({ workspaceId }, task) => { const batches = await task.run("plan", () => listBatches(workspaceId)); for (const [i, batch] of batches.entries()) { @@ -289,68 +237,132 @@ tasks.define( await task.update(`Copying batch ${i + 1}/${batches.length}`); await task.run(`copy-${i}`, () => copyBatch(batch)); } - await task.sleep("settle", 30); // waits without holding a function open - const report = await task.run("verify", () => verifyCopy(workspaceId)); - return { content: [{ type: "text", text: report }] }; + return { content: [{ type: "text", text: "Done" }] }; }, ); ``` -Put side effects inside `task.run`. Code outside a step runs again on every invocation, and each -step is still one invocation, so keep every step within your function limit. - -| | `QStashDispatcher` | `WorkflowDispatcher` | -| --- | --- | --- | -| Survives a crash | Yes, redelivered | Yes, replayed | -| Outlives one invocation | No | Yes, one invocation per step | -| On retry | Starts the handler over | Resumes from the last finished step | + + 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, so set `ttlMs` longer than the work takes. + + | | `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: true` 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. + This holds for `WorkflowDispatcher` too. Without signing keys (or a `receiver` you pass), the + route throws on its first request 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. + + +## Security and storage + + + The server sets a task's owner from `principal`, and no tool argument can set it. `task_status` and + `task_cancel` only accept UUID task ids, and compare the stored owner with the caller. Task ids are + random UUIDs. No tool lists tasks, and the execute route only accepts signed QStash deliveries and + never returns a task. + + Anyone with write access to your Redis can change an owner, so treat the Redis credentials like any + other production secret. + + + + **`mcp:task:`** is 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. + + A task message in QStash carries only `{ taskId }`. The toolkit never stores the caller's token or + the request. + ## Options -**`createTaskLayer`** + + **`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: + `defaults.ttlMs` (5 minutes) and `defaults.pollIntervalMs` (2 seconds). -| Option | Default | | -| --- | --- | --- | -| `store` | required | `RedisTaskStore`, or your own `TaskStore` | -| `dispatcher` | required | `QStashDispatcher`, `WorkflowDispatcher`, or your own `TaskDispatcher` | -| `principal` | required | `({ auth, request }) => string`, may be async. The caller's id; throw to refuse the call. | -| `defaults.ttlMs` | 5 minutes | How long a task is kept. `null` keeps it forever. | -| `defaults.pollIntervalMs` | 2 seconds | Poll interval suggested to the model | -| `toolNames` | `task_status`, `task_cancel` | Rename the two shared tools | -| `onSettle` | none | `(task) => void`. Called once when a task completes, fails or is cancelled. | + **`tasks.define(name, config, handler)`**: `description` and `inputSchema` are required. Optional: + `title`, `completedMessage` (defaults to `"Completed"`). -**`tasks.define(name, config, handler)` config:** `description` and `inputSchema`, plus optional `title`, `ttlMs`, -`pollIntervalMs`, `queuedMessage` and `completedMessage`. + **`RedisTaskStore`**: `redis` (defaults to one from env), `prefix` (`mcp:task:`), `enableTelemetry`. -## Push instead of poll + **`QStashDispatcher`**: `url` is required. Optional: `qstash`, `receiver`, `retries` (5), + `retryDelay`, `enableTelemetry`. -A host that supports MCP Events can get a webhook when a task finishes instead of polling. Wire the -`task.finished` event from [MCP Events](/redis/sdks/agentkit/mcp-events) into `onSettle`; it fires to -the task's owner only: + **`WorkflowDispatcher`**: `url` is required. Optional: `client`, `qstash`, `receiver`, `retries`, + `enableTelemetry`. -```ts lib/tasks.ts -import { taskFinishedEvent } from "@upstash/mcp-toolkit/events"; -import { events } from "./events"; // your event layer + `receiver` defaults to one built from the `QSTASH_*_SIGNING_KEY` variables. 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`. + -const taskFinished = taskFinishedEvent(events); + + `@upstash/mcp-toolkit/tasks` doesn't import anything from Upstash. A backend implements one of these + interfaces (types exported from the same entry point): -export const tasks = createTaskLayer({ - store: new RedisTaskStore(), - dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), - principal, - onSettle: taskFinished.onSettle, -}); -``` + ```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; // idempotent per task id + cancel(taskId: string): Promise; + createExecuteHandler(endpoints: TaskEndpoints): (request: Request) => Promise; + } + ``` + ## Why tools, not the Tasks extension? The 2026-07-28 MCP specification defines a [Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) -where the *client* polls `tasks/get`, so the model spends no turns waiting. It is the right long-term -shape, but a server may only return a task to a client that declares the extension, and the clients -people use don't yet. Plain tools work everywhere today. +where the *client* polls, so the model spends no turns waiting. 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 task record already has the extension's shape, so a native adapter can serve the same records -once clients support it. +The store and dispatcher don't depend on the tools, so an adapter for the extension can serve the +same records once clients support it. Also not implemented yet: `input_required` (a handler asking +the user something partway through a task) and listing tasks. ## Example From f99d1b46f266303963ab132dc403302c5bc536be Mon Sep 17 00:00:00 2001 From: Cahid Arda Date: Thu, 8 Oct 2026 15:13:22 +0000 Subject: [PATCH 09/16] DX-3022: docs: MCP Tasks default TTL is 1 day Follows upstash/agentkit#38, where the default task TTL went from 5 minutes to 1 day. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ --- llms-full.txt | 26 ++++++++++++-------------- redis/sdks/agentkit/mcp-tasks.mdx | 9 ++++----- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 690219d37..09dc905c6 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -15986,7 +15986,7 @@ To view a more detailed Next.js quick start guide for setting up QStash, refer t It's also possible to schedule a background job to run at a later time using [schedules](/docs/qstash/features/schedules). -If you'd like to invoke another endpoint when the background job is complete, you can use [callbacks](/docs/qstash/features/callbacks). +If you'd like to invoke another endpoint when the background job is complete, you can use [callbacks](/docs/qstash/features/callbacks). # Batching Source: https://upstash.com/docs/qstash/features/batch @@ -16285,7 +16285,7 @@ client.message.batch_json( "messageId": "msg_..." } ] -``` +``` # Callbacks Source: https://upstash.com/docs/qstash/features/callbacks @@ -17340,7 +17340,7 @@ from qstash import QStash client = QStash("") client.queue.get("my-queue") ``` - + # Retry Source: https://upstash.com/docs/qstash/features/retry @@ -18039,7 +18039,7 @@ via the [REST API](/docs/qstash/api-reference/dlq/retry-a-dlq-message). ## Retrying Early -A message that is waiting out its retry delay can be retried immediately from the console. See [Run a Delayed Message Now](/docs/qstash/howto/retry-now). +A message that is waiting out its retry delay can be retried immediately from the console. See [Run a Delayed Message Now](/docs/qstash/howto/retry-now). # Local Development Source: https://upstash.com/docs/qstash/howto/local-development @@ -87909,7 +87909,7 @@ progress, then the handler's result) and `task_cancel`. ```jsonc // generate_report: a handle, right away { "content": [{ "type": "text", "text": "Started task 0e30…. Call task_status with taskId \"0e30…\" in about 2s to check on it." }], - "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 300000, "pollIntervalMs": 2000 } } + "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 86400000, "pollIntervalMs": 2000 } } // task_status: progress… { "content": [{ "type": "text", "text": "Task 0e30… is working: Reading sources. Check again in about 2s." }], @@ -87925,7 +87925,7 @@ progress, then the handler's result) and `task_cancel`. task object has the same shape as the one in the MCP Tasks extension. If the model stops polling, nothing is lost: the work finishes anyway, and the result can be read - until the task expires (5 minutes by default; set `defaults: { ttlMs }` on the layer). + until the task expires (1 day by default; set `defaults: { ttlMs }` on the layer). ## Cancellation @@ -87999,7 +87999,6 @@ export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), principal, - defaults: { ttlMs: 60 * 60 * 1000 }, // keep the record longer than the work takes }); tasks.define( @@ -88029,7 +88028,7 @@ tasks.define( * Each step must still fit within your function's time limit. The task's TTL starts when the task is created and is never extended. When it runs out, the record - is deleted and `isCancelled()` returns true, so set `ttlMs` longer than the work takes. + is deleted and `isCancelled()` returns true. The default is 1 day; set `ttlMs` higher for work that can take longer. | | `QStashDispatcher` | `WorkflowDispatcher` | | --- | --- | --- | @@ -88090,7 +88089,7 @@ tasks.define( **`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: - `defaults.ttlMs` (5 minutes) and `defaults.pollIntervalMs` (2 seconds). + `defaults.ttlMs` (1 day) and `defaults.pollIntervalMs` (2 seconds). **`tasks.define(name, config, handler)`**: `description` and `inputSchema` are required. Optional: `title`, `completedMessage` (defaults to `"Completed"`). @@ -88277,7 +88276,7 @@ progress, then the handler's result) and `task_cancel`. ```jsonc // generate_report: a handle, right away { "content": [{ "type": "text", "text": "Started task 0e30…. Call task_status with taskId \"0e30…\" in about 2s to check on it." }], - "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 300000, "pollIntervalMs": 2000 } } + "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 86400000, "pollIntervalMs": 2000 } } // task_status: progress… { "content": [{ "type": "text", "text": "Task 0e30… is working: Reading sources. Check again in about 2s." }], @@ -88293,7 +88292,7 @@ progress, then the handler's result) and `task_cancel`. task object has the same shape as the one in the MCP Tasks extension. If the model stops polling, nothing is lost: the work finishes anyway, and the result can be read - until the task expires (5 minutes by default; set `defaults: { ttlMs }` on the layer). + until the task expires (1 day by default; set `defaults: { ttlMs }` on the layer). ## Cancellation @@ -88367,7 +88366,6 @@ export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), principal, - defaults: { ttlMs: 60 * 60 * 1000 }, // keep the record longer than the work takes }); tasks.define( @@ -88397,7 +88395,7 @@ tasks.define( * Each step must still fit within your function's time limit. The task's TTL starts when the task is created and is never extended. When it runs out, the record - is deleted and `isCancelled()` returns true, so set `ttlMs` longer than the work takes. + is deleted and `isCancelled()` returns true. The default is 1 day; set `ttlMs` higher for work that can take longer. | | `QStashDispatcher` | `WorkflowDispatcher` | | --- | --- | --- | @@ -88458,7 +88456,7 @@ tasks.define( **`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: - `defaults.ttlMs` (5 minutes) and `defaults.pollIntervalMs` (2 seconds). + `defaults.ttlMs` (1 day) and `defaults.pollIntervalMs` (2 seconds). **`tasks.define(name, config, handler)`**: `description` and `inputSchema` are required. Optional: `title`, `completedMessage` (defaults to `"Completed"`). diff --git a/redis/sdks/agentkit/mcp-tasks.mdx b/redis/sdks/agentkit/mcp-tasks.mdx index e6f5ad4df..d75aed3d1 100644 --- a/redis/sdks/agentkit/mcp-tasks.mdx +++ b/redis/sdks/agentkit/mcp-tasks.mdx @@ -134,7 +134,7 @@ progress, then the handler's result) and `task_cancel`. ```jsonc // generate_report: a handle, right away { "content": [{ "type": "text", "text": "Started task 0e30…. Call task_status with taskId \"0e30…\" in about 2s to check on it." }], - "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 300000, "pollIntervalMs": 2000 } } + "structuredContent": { "taskId": "0e30…", "status": "working", "ttlMs": 86400000, "pollIntervalMs": 2000 } } // task_status: progress… { "content": [{ "type": "text", "text": "Task 0e30… is working: Reading sources. Check again in about 2s." }], @@ -150,7 +150,7 @@ progress, then the handler's result) and `task_cancel`. task object has the same shape as the one in the MCP Tasks extension. If the model stops polling, nothing is lost: the work finishes anyway, and the result can be read - until the task expires (5 minutes by default; set `defaults: { ttlMs }` on the layer). + until the task expires (1 day by default; set `defaults: { ttlMs }` on the layer). ## Cancellation @@ -224,7 +224,6 @@ export const tasks = createTaskLayer({ store: new RedisTaskStore(), dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), principal, - defaults: { ttlMs: 60 * 60 * 1000 }, // keep the record longer than the work takes }); tasks.define( @@ -254,7 +253,7 @@ tasks.define( - Each step must still fit within your function's time limit. The task's TTL starts when the task is created and is never extended. When it runs out, the record - is deleted and `isCancelled()` returns true, so set `ttlMs` longer than the work takes. + is deleted and `isCancelled()` returns true. The default is 1 day; set `ttlMs` higher for work that can take longer. | | `QStashDispatcher` | `WorkflowDispatcher` | | --- | --- | --- | @@ -315,7 +314,7 @@ tasks.define( **`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: - `defaults.ttlMs` (5 minutes) and `defaults.pollIntervalMs` (2 seconds). + `defaults.ttlMs` (1 day) and `defaults.pollIntervalMs` (2 seconds). **`tasks.define(name, config, handler)`**: `description` and `inputSchema` are required. Optional: `title`, `completedMessage` (defaults to `"Completed"`). From 0a9aa8c2cae9409103cd05d759aac34d0b93823e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:13:56 +0000 Subject: [PATCH 10/16] chore(llms): regenerate llms.txt and llms-full.txt --- llms-full.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 09dc905c6..0ba4ad234 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -15986,7 +15986,7 @@ To view a more detailed Next.js quick start guide for setting up QStash, refer t It's also possible to schedule a background job to run at a later time using [schedules](/docs/qstash/features/schedules). -If you'd like to invoke another endpoint when the background job is complete, you can use [callbacks](/docs/qstash/features/callbacks). +If you'd like to invoke another endpoint when the background job is complete, you can use [callbacks](/docs/qstash/features/callbacks). # Batching Source: https://upstash.com/docs/qstash/features/batch @@ -16285,7 +16285,7 @@ client.message.batch_json( "messageId": "msg_..." } ] -``` +``` # Callbacks Source: https://upstash.com/docs/qstash/features/callbacks @@ -17340,7 +17340,7 @@ from qstash import QStash client = QStash("") client.queue.get("my-queue") ``` - + # Retry Source: https://upstash.com/docs/qstash/features/retry @@ -18039,7 +18039,7 @@ via the [REST API](/docs/qstash/api-reference/dlq/retry-a-dlq-message). ## Retrying Early -A message that is waiting out its retry delay can be retried immediately from the console. See [Run a Delayed Message Now](/docs/qstash/howto/retry-now). +A message that is waiting out its retry delay can be retried immediately from the console. See [Run a Delayed Message Now](/docs/qstash/howto/retry-now). # Local Development Source: https://upstash.com/docs/qstash/howto/local-development From 0ef135cfc94a4192e0c21df207ec6ade77011619 Mon Sep 17 00:00:00 2001 From: Cahid Arda Date: Thu, 8 Oct 2026 21:12:14 +0000 Subject: [PATCH 11/16] DX-3022: docs: task authorize + task.principal, subscription limit, input-schema routing - MCP Tasks: 'Check what the arguments point at' section (authorize on tasks.define, task.principal in the handler); migrate_workspace example uses both; options and custom-backend notes - MCP Events: maxSubscriptions (8 per subscriber) and the subscription_limit reason; routing values parsed with the input schema; eventId dedup now via webhook-id; by: key; SubscriptionStore interface Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n --- redis/sdks/agentkit/mcp-events.mdx | 28 +++++++++++----- redis/sdks/agentkit/mcp-tasks.mdx | 51 ++++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/redis/sdks/agentkit/mcp-events.mdx b/redis/sdks/agentkit/mcp-events.mdx index 4b1dc1aae..64b7e40a1 100644 --- a/redis/sdks/agentkit/mcp-events.mdx +++ b/redis/sdks/agentkit/mcp-events.mdx @@ -163,8 +163,10 @@ host can drop duplicates, and the signature is computed fresh on every attempt. ## Matching and authorize Every `input` field must also be a `payload` field: the payload is the one place an event's values -come from, and the same values are used to route it and to authorize it. A subscription matches when -each argument it gave equals the payload's value. Emitting `{ documentId: "doc_123", text }` reaches +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 (`.toLowerCase()`, say) apply on +both sides and `authorize` gets the types it declares; a payload whose values don't fit `input` makes +`emit` throw. 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: @@ -181,7 +183,8 @@ The token is never stored, so the delivery-time check gets no `auth` or `request `authorize: () => true` lets every authenticated subscriber hear every matching event. - Pass `{ eventId }` as the second argument to `emit`. Emitting the same id twice delivers once: + Pass `{ eventId }` as the second argument to `emit`. It is sent as `webhook-id`, so the host drops a + second emit with the same id as a duplicate: ```ts await commentCreated.emit({ documentId, text }, { eventId: comment.id }); @@ -224,6 +227,11 @@ The token is never stored, so the delivery-time check gets no `auth` or `request sending unverified deliveries. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`. - **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. Past that, a new + subscribe is refused with reason `subscription_limit`, before the callback is challenged; + 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. Set `maxSubscriptions` to change it, or + `Infinity` to turn it off. @@ -242,8 +250,9 @@ The token is never stored, so the delivery-time check gets no `auth` or `request **`mcp-events:sub:`** is the subscription (`event`, `args`, `url`, `encryptedSecret`, - `subscriber`, `createdAt`, `expiresAt`), expiring with it. **`mcp-events:idx:`** is a sorted - set of the event's subscription ids, scored by expiry. + `subscriber`, `createdAt`, `expiresAt`), expiring with it. **`mcp-events:idx:`** and + **`mcp-events:by:`** are sorted sets of the event's and the subscriber's subscription + ids, scored by expiry. The second one enforces the per-subscriber limit. 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 @@ -255,7 +264,8 @@ The token is never stored, so the delivery-time check gets no `auth` or `request **`createEventLayer`**: `store`, `delivery` and `principal` are required, and so is `secretKey` - unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `allowInsecureCallbacks`. + unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `maxSubscriptions` (8 per subscriber), + `allowInsecureCallbacks`. **`events.define(name, config)`**: `description`, `payload` and `authorize` are required. Optional: `title`, `input`. @@ -273,9 +283,11 @@ The token is never stored, so the delivery-time check gets no `auth` or `request ```ts interface SubscriptionStore { - put(subscription: Subscription): Promise; + // false, storing nothing, when a new one would put its subscriber over `limit` (atomically) + put(subscription: Subscription, options: { limit: number }): Promise; get(id: string): Promise; - delete(subscription: { id: string; event: string }): Promise; + count(subscriber: string): Promise; // live subscriptions, across all events + delete(subscription: { id: string; event: string; subscriber: string }): Promise; find(event: string): Promise; // every live subscription to the event } diff --git a/redis/sdks/agentkit/mcp-tasks.mdx b/redis/sdks/agentkit/mcp-tasks.mdx index d75aed3d1..44a4a992b 100644 --- a/redis/sdks/agentkit/mcp-tasks.mdx +++ b/redis/sdks/agentkit/mcp-tasks.mdx @@ -182,6 +182,39 @@ async ({ sources }, task) => { Each task records who started it, and `task_status` / `task_cancel` only answer for that caller. Another user's task id reads exactly like an unknown one, so nobody can tell it exists. +### Check what the arguments point at + +Owning the task is not the same as being allowed to touch what it works on. The model fills in the +tool arguments, so a `workspaceId` in them is whatever it was told. If Alice starts a task with Bob's +`workspaceId`, the task is hers, she can read its result, and the handler acted on Bob's workspace. + +Give the task an `authorize`, which runs when the tool is called, before anything 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 lib/tasks.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 }] }; + }, +); +``` + +`authorize(args, { principal, auth, request })` gets the validated arguments, the caller's id and the +same `auth` and `request` as `principal`. It is the only place a task sees the caller's token: the +handler runs later, in a delivery request that carries no user auth, so `task.principal` is how it +knows who it is working for. + +### Where principal comes from + `principal` receives `{ auth, request }` and may be async. `auth` is the MCP SDK's `AuthInfo`. The SDK never fills it in from request headers: your MCP route does, after verifying the bearer token, by passing it to `handler.fetch(request, { authInfo })` as in step 3. Put the user id in `extra` when you @@ -228,9 +261,13 @@ export const tasks = createTaskLayer({ tasks.define( "migrate_workspace", - { description: "Copies a workspace to new storage.", inputSchema: z.object({ workspaceId: z.string() }) }, + { + description: "Copies a workspace to new storage.", + inputSchema: z.object({ workspaceId: z.string() }), + authorize: ({ workspaceId }, { principal }) => isOwner(principal, workspaceId), + }, async ({ workspaceId }, task) => { - const batches = await task.run("plan", () => listBatches(workspaceId)); + const batches = await task.run("plan", () => listBatches(workspaceId, task.principal)); for (const [i, batch] of batches.entries()) { if (await task.isCancelled()) return {}; await task.update(`Copying batch ${i + 1}/${batches.length}`); @@ -283,7 +320,9 @@ tasks.define( ## Security and storage - The server sets a task's owner from `principal`, and no tool argument can set it. `task_status` and + The server sets a task's 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. Task ids are random UUIDs. No tool lists tasks, and the execute route only accepts signed QStash deliveries and never returns a task. @@ -314,10 +353,10 @@ tasks.define( **`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: - `defaults.ttlMs` (1 day) and `defaults.pollIntervalMs` (2 seconds). + `defaults.ttlMs` (1 day) and `defaults.pollIntervalMs` (2 seconds), in positive whole milliseconds. **`tasks.define(name, config, handler)`**: `description` and `inputSchema` are required. Optional: - `title`, `completedMessage` (defaults to `"Completed"`). + `title`, `completedMessage` (defaults to `"Completed"`), `authorize(args, { principal, auth, request })`. **`RedisTaskStore`**: `redis` (defaults to one from env), `prefix` (`mcp:task:`), `enableTelemetry`. @@ -345,7 +384,7 @@ tasks.define( } interface TaskDispatcher { - dispatch(task: Task): Promise; // idempotent per task id + dispatch(task: Task): Promise; // called once per task cancel(taskId: string): Promise; createExecuteHandler(endpoints: TaskEndpoints): (request: Request) => Promise; } From 749c28e69cf61ae59c3dae14fc2bc7b5b2ca0060 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:13:21 +0000 Subject: [PATCH 12/16] chore(llms): regenerate llms.txt and llms-full.txt --- llms-full.txt | 158 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 130 insertions(+), 28 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 0ba4ad234..c93728fdb 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -87342,8 +87342,10 @@ host can drop duplicates, and the signature is computed fresh on every attempt. ## Matching and authorize Every `input` field must also be a `payload` field: the payload is the one place an event's values -come from, and the same values are used to route it and to authorize it. A subscription matches when -each argument it gave equals the payload's value. Emitting `{ documentId: "doc_123", text }` reaches +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 (`.toLowerCase()`, say) apply on +both sides and `authorize` gets the types it declares; a payload whose values don't fit `input` makes +`emit` throw. 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: @@ -87360,7 +87362,8 @@ The token is never stored, so the delivery-time check gets no `auth` or `request `authorize: () => true` lets every authenticated subscriber hear every matching event. - Pass `{ eventId }` as the second argument to `emit`. Emitting the same id twice delivers once: + Pass `{ eventId }` as the second argument to `emit`. It is sent as `webhook-id`, so the host drops a + second emit with the same id as a duplicate: ```ts await commentCreated.emit({ documentId, text }, { eventId: comment.id }); @@ -87403,6 +87406,11 @@ The token is never stored, so the delivery-time check gets no `auth` or `request sending unverified deliveries. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`. * **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. Past that, a new + subscribe is refused with reason `subscription_limit`, before the callback is challenged; + 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. Set `maxSubscriptions` to change it, or + `Infinity` to turn it off. @@ -87421,8 +87429,9 @@ The token is never stored, so the delivery-time check gets no `auth` or `request **`mcp-events:sub:`** is the subscription (`event`, `args`, `url`, `encryptedSecret`, - `subscriber`, `createdAt`, `expiresAt`), expiring with it. **`mcp-events:idx:`** is a sorted - set of the event's subscription ids, scored by expiry. + `subscriber`, `createdAt`, `expiresAt`), expiring with it. **`mcp-events:idx:`** and + **`mcp-events:by:`** are sorted sets of the event's and the subscriber's subscription + ids, scored by expiry. The second one enforces the per-subscriber limit. 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 @@ -87434,7 +87443,8 @@ The token is never stored, so the delivery-time check gets no `auth` or `request **`createEventLayer`**: `store`, `delivery` and `principal` are required, and so is `secretKey` - unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `allowInsecureCallbacks`. + unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `maxSubscriptions` (8 per subscriber), + `allowInsecureCallbacks`. **`events.define(name, config)`**: `description`, `payload` and `authorize` are required. Optional: `title`, `input`. @@ -87452,9 +87462,11 @@ The token is never stored, so the delivery-time check gets no `auth` or `request ```ts interface SubscriptionStore { - put(subscription: Subscription): Promise; + // false, storing nothing, when a new one would put its subscriber over `limit` (atomically) + put(subscription: Subscription, options: { limit: number }): Promise; get(id: string): Promise; - delete(subscription: { id: string; event: string }): Promise; + count(subscriber: string): Promise; // live subscriptions, across all events + delete(subscription: { id: string; event: string; subscriber: string }): Promise; find(event: string): Promise; // every live subscription to the event } @@ -87640,8 +87652,10 @@ host can drop duplicates, and the signature is computed fresh on every attempt. ## Matching and authorize Every `input` field must also be a `payload` field: the payload is the one place an event's values -come from, and the same values are used to route it and to authorize it. A subscription matches when -each argument it gave equals the payload's value. Emitting `{ documentId: "doc_123", text }` reaches +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 (`.toLowerCase()`, say) apply on +both sides and `authorize` gets the types it declares; a payload whose values don't fit `input` makes +`emit` throw. 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: @@ -87658,7 +87672,8 @@ The token is never stored, so the delivery-time check gets no `auth` or `request `authorize: () => true` lets every authenticated subscriber hear every matching event. - Pass `{ eventId }` as the second argument to `emit`. Emitting the same id twice delivers once: + Pass `{ eventId }` as the second argument to `emit`. It is sent as `webhook-id`, so the host drops a + second emit with the same id as a duplicate: ```ts await commentCreated.emit({ documentId, text }, { eventId: comment.id }); @@ -87701,6 +87716,11 @@ The token is never stored, so the delivery-time check gets no `auth` or `request sending unverified deliveries. A bad signature or body gets `489` with `Upstash-NonRetryable-Error: true`. * **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. Past that, a new + subscribe is refused with reason `subscription_limit`, before the callback is challenged; + 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. Set `maxSubscriptions` to change it, or + `Infinity` to turn it off. @@ -87719,8 +87739,9 @@ The token is never stored, so the delivery-time check gets no `auth` or `request **`mcp-events:sub:`** is the subscription (`event`, `args`, `url`, `encryptedSecret`, - `subscriber`, `createdAt`, `expiresAt`), expiring with it. **`mcp-events:idx:`** is a sorted - set of the event's subscription ids, scored by expiry. + `subscriber`, `createdAt`, `expiresAt`), expiring with it. **`mcp-events:idx:`** and + **`mcp-events:by:`** are sorted sets of the event's and the subscriber's subscription + ids, scored by expiry. The second one enforces the per-subscriber limit. 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 @@ -87732,7 +87753,8 @@ The token is never stored, so the delivery-time check gets no `auth` or `request **`createEventLayer`**: `store`, `delivery` and `principal` are required, and so is `secretKey` - unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `allowInsecureCallbacks`. + unless `MCP_EVENTS_SECRET_KEY` is set. Optional: `maxSubscriptions` (8 per subscriber), + `allowInsecureCallbacks`. **`events.define(name, config)`**: `description`, `payload` and `authorize` are required. Optional: `title`, `input`. @@ -87750,9 +87772,11 @@ The token is never stored, so the delivery-time check gets no `auth` or `request ```ts interface SubscriptionStore { - put(subscription: Subscription): Promise; + // false, storing nothing, when a new one would put its subscriber over `limit` (atomically) + put(subscription: Subscription, options: { limit: number }): Promise; get(id: string): Promise; - delete(subscription: { id: string; event: string }): Promise; + count(subscriber: string): Promise; // live subscriptions, across all events + delete(subscription: { id: string; event: string; subscriber: string }): Promise; find(event: string): Promise; // every live subscription to the event } @@ -87957,6 +87981,39 @@ async ({ sources }, task) => { Each task records who started it, and `task_status` / `task_cancel` only answer for that caller. Another user's task id reads exactly like an unknown one, so nobody can tell it exists. +### Check what the arguments point at + +Owning the task is not the same as being allowed to touch what it works on. The model fills in the +tool arguments, so a `workspaceId` in them is whatever it was told. If Alice starts a task with Bob's +`workspaceId`, the task is hers, she can read its result, and the handler acted on Bob's workspace. + +Give the task an `authorize`, which runs when the tool is called, before anything 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 lib/tasks.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 }] }; + }, +); +``` + +`authorize(args, { principal, auth, request })` gets the validated arguments, the caller's id and the +same `auth` and `request` as `principal`. It is the only place a task sees the caller's token: the +handler runs later, in a delivery request that carries no user auth, so `task.principal` is how it +knows who it is working for. + +### Where principal comes from + `principal` receives `{ auth, request }` and may be async. `auth` is the MCP SDK's `AuthInfo`. The SDK never fills it in from request headers: your MCP route does, after verifying the bearer token, by passing it to `handler.fetch(request, { authInfo })` as in step 3. Put the user id in `extra` when you @@ -88003,9 +88060,13 @@ export const tasks = createTaskLayer({ tasks.define( "migrate_workspace", - { description: "Copies a workspace to new storage.", inputSchema: z.object({ workspaceId: z.string() }) }, + { + description: "Copies a workspace to new storage.", + inputSchema: z.object({ workspaceId: z.string() }), + authorize: ({ workspaceId }, { principal }) => isOwner(principal, workspaceId), + }, async ({ workspaceId }, task) => { - const batches = await task.run("plan", () => listBatches(workspaceId)); + const batches = await task.run("plan", () => listBatches(workspaceId, task.principal)); for (const [i, batch] of batches.entries()) { if (await task.isCancelled()) return {}; await task.update(`Copying batch ${i + 1}/${batches.length}`); @@ -88058,7 +88119,9 @@ tasks.define( ## Security and storage - The server sets a task's owner from `principal`, and no tool argument can set it. `task_status` and + The server sets a task's 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. Task ids are random UUIDs. No tool lists tasks, and the execute route only accepts signed QStash deliveries and never returns a task. @@ -88089,10 +88152,10 @@ tasks.define( **`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: - `defaults.ttlMs` (1 day) and `defaults.pollIntervalMs` (2 seconds). + `defaults.ttlMs` (1 day) and `defaults.pollIntervalMs` (2 seconds), in positive whole milliseconds. **`tasks.define(name, config, handler)`**: `description` and `inputSchema` are required. Optional: - `title`, `completedMessage` (defaults to `"Completed"`). + `title`, `completedMessage` (defaults to `"Completed"`), `authorize(args, { principal, auth, request })`. **`RedisTaskStore`**: `redis` (defaults to one from env), `prefix` (`mcp:task:`), `enableTelemetry`. @@ -88120,7 +88183,7 @@ tasks.define( } interface TaskDispatcher { - dispatch(task: Task): Promise; // idempotent per task id + dispatch(task: Task): Promise; // called once per task cancel(taskId: string): Promise; createExecuteHandler(endpoints: TaskEndpoints): (request: Request) => Promise; } @@ -88324,6 +88387,39 @@ async ({ sources }, task) => { Each task records who started it, and `task_status` / `task_cancel` only answer for that caller. Another user's task id reads exactly like an unknown one, so nobody can tell it exists. +### Check what the arguments point at + +Owning the task is not the same as being allowed to touch what it works on. The model fills in the +tool arguments, so a `workspaceId` in them is whatever it was told. If Alice starts a task with Bob's +`workspaceId`, the task is hers, she can read its result, and the handler acted on Bob's workspace. + +Give the task an `authorize`, which runs when the tool is called, before anything 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 lib/tasks.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 }] }; + }, +); +``` + +`authorize(args, { principal, auth, request })` gets the validated arguments, the caller's id and the +same `auth` and `request` as `principal`. It is the only place a task sees the caller's token: the +handler runs later, in a delivery request that carries no user auth, so `task.principal` is how it +knows who it is working for. + +### Where principal comes from + `principal` receives `{ auth, request }` and may be async. `auth` is the MCP SDK's `AuthInfo`. The SDK never fills it in from request headers: your MCP route does, after verifying the bearer token, by passing it to `handler.fetch(request, { authInfo })` as in step 3. Put the user id in `extra` when you @@ -88370,9 +88466,13 @@ export const tasks = createTaskLayer({ tasks.define( "migrate_workspace", - { description: "Copies a workspace to new storage.", inputSchema: z.object({ workspaceId: z.string() }) }, + { + description: "Copies a workspace to new storage.", + inputSchema: z.object({ workspaceId: z.string() }), + authorize: ({ workspaceId }, { principal }) => isOwner(principal, workspaceId), + }, async ({ workspaceId }, task) => { - const batches = await task.run("plan", () => listBatches(workspaceId)); + const batches = await task.run("plan", () => listBatches(workspaceId, task.principal)); for (const [i, batch] of batches.entries()) { if (await task.isCancelled()) return {}; await task.update(`Copying batch ${i + 1}/${batches.length}`); @@ -88425,7 +88525,9 @@ tasks.define( ## Security and storage - The server sets a task's owner from `principal`, and no tool argument can set it. `task_status` and + The server sets a task's 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. Task ids are random UUIDs. No tool lists tasks, and the execute route only accepts signed QStash deliveries and never returns a task. @@ -88456,10 +88558,10 @@ tasks.define( **`createTaskLayer`**: `store`, `dispatcher` and `principal` are required. Optional: - `defaults.ttlMs` (1 day) and `defaults.pollIntervalMs` (2 seconds). + `defaults.ttlMs` (1 day) and `defaults.pollIntervalMs` (2 seconds), in positive whole milliseconds. **`tasks.define(name, config, handler)`**: `description` and `inputSchema` are required. Optional: - `title`, `completedMessage` (defaults to `"Completed"`). + `title`, `completedMessage` (defaults to `"Completed"`), `authorize(args, { principal, auth, request })`. **`RedisTaskStore`**: `redis` (defaults to one from env), `prefix` (`mcp:task:`), `enableTelemetry`. @@ -88487,7 +88589,7 @@ tasks.define( } interface TaskDispatcher { - dispatch(task: Task): Promise; // idempotent per task id + dispatch(task: Task): Promise; // called once per task cancel(taskId: string): Promise; createExecuteHandler(endpoints: TaskEndpoints): (request: Request) => Promise; } From 8233ad5369e2ae34401b9fd6217674e37c9482b4 Mon Sep 17 00:00:00 2001 From: Cahid Arda Date: Thu, 8 Oct 2026 21:25:35 +0000 Subject: [PATCH 13/16] DX-3022: docs: show where AuthInfo is built (mcp-handler withMcpAuth) lib/auth.ts now holds verifyToken (builds AuthInfo, wired with withMcpAuth) next to principal; the MCP route uses mcp-handler's createMcpHandler + withMcpAuth, as upstash-mcp-server does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n --- redis/sdks/agentkit/mcp-events.mdx | 59 ++++++++++++++-------------- redis/sdks/agentkit/mcp-tasks.mdx | 62 ++++++++++++++++-------------- 2 files changed, 64 insertions(+), 57 deletions(-) diff --git a/redis/sdks/agentkit/mcp-events.mdx b/redis/sdks/agentkit/mcp-events.mdx index 64b7e40a1..45bcd398e 100644 --- a/redis/sdks/agentkit/mcp-events.mdx +++ b/redis/sdks/agentkit/mcp-events.mdx @@ -21,7 +21,7 @@ signing and retries: subscriptions live in Upstash Redis, and every delivery goe ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server mcp-handler zod ``` ## Quickstart @@ -30,14 +30,25 @@ Four files in a Next.js app. ### 1. Identify the caller -Every subscription belongs to the user who subscribed. `principal` returns that user's id, read from -the token your MCP route has verified, and throws when there is none. If you also use -[MCP Tasks](/redis/sdks/agentkit/mcp-tasks), both layers share this function: +Every subscription belongs to the user who subscribed. `verifyToken` turns the request's bearer token +into the SDK's `AuthInfo`, with your user id in `extra`, and `principal` reads that id back for the +toolkit. If you also use [MCP Tasks](/redis/sdks/agentkit/mcp-tasks), both layers share this file: ```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). +// 1. Runs on every MCP request (wired up with withMcpAuth in step 3). +// Returning undefined answers 401. +export async function verifyToken( + req: Request, + bearerToken?: string, +): Promise { + if (!bearerToken) return undefined; + const { sub, client_id } = await verifyJwt(bearerToken); // Clerk, WorkOS, Auth0, your own + return { token: bearerToken, clientId: client_id, scopes: [], extra: { userId: sub } }; +} + +// 2. The toolkit calls this with the AuthInfo above. Return the user id, or throw. export function principal({ auth }: { auth?: AuthInfo }): string { const userId = auth?.extra?.userId; if (typeof userId !== "string") throw new Error("Not authenticated"); @@ -70,36 +81,26 @@ export const commentCreated = events.define("comment.created", { `authorize` is required. See [Matching and authorize](#matching-and-authorize) for when it runs. -### 3. Register the events on your MCP server - -```ts lib/server.ts -import { McpServer } from "@modelcontextprotocol/server"; -import { events } from "./events"; - -export function createServer() { - const server = new McpServer({ name: "docs", version: "1.0.0" }); - events.register(server); // adds events/list, events/subscribe and events/unsubscribe - return server; -} -``` - -Serve it from your MCP route with the verified token, as in the -[MCP Tasks quickstart](/redis/sdks/agentkit/mcp-tasks#quickstart): +### 3. Serve the MCP endpoint ```ts app/api/mcp/route.ts -import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; -import { createServer } from "../../../lib/server"; +import { createMcpHandler, withMcpAuth } from "mcp-handler"; +import { verifyToken } from "../../../lib/auth"; +import { events } from "../../../lib/events"; -const handler = createMcpHandler(() => createServer()); +const handler = createMcpHandler((server) => { + events.register(server); // adds events/list, events/subscribe and events/unsubscribe +}); -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 }); -} +// Runs verifyToken before any request is handled; no AuthInfo means 401. +const authHandler = withMcpAuth(handler, verifyToken, { required: true }); +export { authHandler as GET, authHandler as POST }; ``` +`withMcpAuth` hands the `AuthInfo` from `verifyToken` to the MCP server, and the toolkit passes it +to `principal` on every subscribe. With [MCP Tasks](/redis/sdks/agentkit/mcp-tasks) too, call +`tasks.register(server)` in the same callback. + ### 4. Add the delivery endpoint ```ts app/api/events/route.ts diff --git a/redis/sdks/agentkit/mcp-tasks.mdx b/redis/sdks/agentkit/mcp-tasks.mdx index 44a4a992b..ce5f7acc8 100644 --- a/redis/sdks/agentkit/mcp-tasks.mdx +++ b/redis/sdks/agentkit/mcp-tasks.mdx @@ -17,7 +17,7 @@ OpenCode and ChatGPT. No client capability is required. The toolkit sits on top TypeScript SDK and uses WebCrypto only, so it runs on Node and edge runtimes. ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server mcp-handler zod ``` ## Quickstart @@ -26,13 +26,27 @@ Four files in a Next.js app. ### 1. Identify the caller -Every task belongs to the user who started it. `principal` returns that user's id, read from the -token your MCP route has verified, and throws when there is none: +Every task belongs to the user who started it. Two functions, kept in one file so the path is easy +to follow: + +- `verifyToken` runs on every MCP request. It verifies the bearer token and returns the SDK's + `AuthInfo`, with your user id in `extra`. Step 3 wires it up with `withMcpAuth`. +- `principal` is what the toolkit calls. It gets that `AuthInfo` and returns the user id, or throws. ```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). +// 1. Runs on every MCP request. Returning undefined answers 401. +export async function verifyToken( + req: Request, + bearerToken?: string, +): Promise { + if (!bearerToken) return undefined; + const { sub, client_id } = await verifyJwt(bearerToken); // Clerk, WorkOS, Auth0, your own + return { token: bearerToken, clientId: client_id, scopes: [], extra: { userId: sub } }; +} + +// 2. The toolkit calls this with the AuthInfo above. Return the user id, or throw. export function principal({ auth }: { auth?: AuthInfo }): string { const userId = auth?.extra?.userId; if (typeof userId !== "string") throw new Error("Not authenticated"); @@ -40,12 +54,9 @@ export function principal({ auth }: { auth?: AuthInfo }): string { } ``` -See [Users](#users) for where `auth` comes from. - -### 2. Define the task layer, the task and the server +### 2. Define the task layer and the task ```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"; @@ -66,12 +77,6 @@ tasks.define( 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; -} ``` The handler runs later, in `/api/execute`, when QStash delivers the task, not inside the tool call. @@ -80,20 +85,21 @@ The handler runs later, in `/api/execute`, when QStash delivers the task, not in ### 3. Serve the MCP endpoint ```ts app/api/mcp/route.ts -import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; -import { createServer } from "../../../lib/tasks"; +import { createMcpHandler, withMcpAuth } from "mcp-handler"; +import { verifyToken } from "../../../lib/auth"; +import { tasks } from "../../../lib/tasks"; -const handler = createMcpHandler(() => createServer()); +const handler = createMcpHandler((server) => { + tasks.register(server); // adds generate_report, task_status and task_cancel +}); -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 }); -} +// Runs verifyToken before any tool sees the request; no AuthInfo means 401. +const authHandler = withMcpAuth(handler, verifyToken, { required: true }); +export { authHandler as GET, authHandler as POST }; ``` -The task layer only registers tools, so this is the SDK's own handler, unchanged. +`withMcpAuth` hands the `AuthInfo` from `verifyToken` to the MCP server, and the toolkit passes it +to `principal`. The task layer only registers tools, so nothing else changes in your route. ### 4. Add the execute endpoint @@ -215,10 +221,10 @@ knows who it is working for. ### Where principal comes from -`principal` receives `{ auth, request }` and may be async. `auth` is the MCP SDK's `AuthInfo`. The SDK -never fills it in from request headers: your MCP route does, after verifying the bearer token, by -passing it to `handler.fetch(request, { authInfo })` as in step 3. Put the user id in `extra` when you -build `authInfo`, and read it back in `principal`. +`principal` receives `{ auth, request }` and may be async. `auth` is exactly what `verifyToken` +returned in [step 1](#1-identify-the-caller): `withMcpAuth` runs it, and the toolkit hands the result +to `principal`. Nothing is read from request headers on its own. Without `mcp-handler`, pass the +`AuthInfo` yourself with the SDK's `createMcpHandler`: `handler.fetch(request, { authInfo })`. - **A throw refuses the call.** There is no anonymous mode: if `principal` throws, rejects, or From 300597be78fc99dd0aa1b4e77e5e1b20a7ba2240 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:26:06 +0000 Subject: [PATCH 14/16] chore(llms): regenerate llms.txt and llms-full.txt --- llms-full.txt | 242 ++++++++++++++++++++++++++------------------------ 1 file changed, 128 insertions(+), 114 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index c93728fdb..e1948bb18 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -87200,7 +87200,7 @@ signing and retries: subscriptions live in Upstash Redis, and every delivery goe ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server mcp-handler zod ``` ## Quickstart @@ -87209,14 +87209,25 @@ Four files in a Next.js app. ### 1. Identify the caller -Every subscription belongs to the user who subscribed. `principal` returns that user's id, read from -the token your MCP route has verified, and throws when there is none. If you also use -[MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks), both layers share this function: +Every subscription belongs to the user who subscribed. `verifyToken` turns the request's bearer token +into the SDK's `AuthInfo`, with your user id in `extra`, and `principal` reads that id back for the +toolkit. If you also use [MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks), both layers share this file: ```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). +// 1. Runs on every MCP request (wired up with withMcpAuth in step 3). +// Returning undefined answers 401. +export async function verifyToken( + req: Request, + bearerToken?: string, +): Promise { + if (!bearerToken) return undefined; + const { sub, client_id } = await verifyJwt(bearerToken); // Clerk, WorkOS, Auth0, your own + return { token: bearerToken, clientId: client_id, scopes: [], extra: { userId: sub } }; +} + +// 2. The toolkit calls this with the AuthInfo above. Return the user id, or throw. export function principal({ auth }: { auth?: AuthInfo }): string { const userId = auth?.extra?.userId; if (typeof userId !== "string") throw new Error("Not authenticated"); @@ -87249,36 +87260,26 @@ export const commentCreated = events.define("comment.created", { `authorize` is required. See [Matching and authorize](#matching-and-authorize) for when it runs. -### 3. Register the events on your MCP server - -```ts lib/server.ts -import { McpServer } from "@modelcontextprotocol/server"; -import { events } from "./events"; - -export function createServer() { - const server = new McpServer({ name: "docs", version: "1.0.0" }); - events.register(server); // adds events/list, events/subscribe and events/unsubscribe - return server; -} -``` - -Serve it from your MCP route with the verified token, as in the -[MCP Tasks quickstart](/docs/redis/sdks/agentkit/mcp-tasks#quickstart): +### 3. Serve the MCP endpoint ```ts app/api/mcp/route.ts -import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; -import { createServer } from "../../../lib/server"; +import { createMcpHandler, withMcpAuth } from "mcp-handler"; +import { verifyToken } from "../../../lib/auth"; +import { events } from "../../../lib/events"; -const handler = createMcpHandler(() => createServer()); +const handler = createMcpHandler((server) => { + events.register(server); // adds events/list, events/subscribe and events/unsubscribe +}); -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 }); -} +// Runs verifyToken before any request is handled; no AuthInfo means 401. +const authHandler = withMcpAuth(handler, verifyToken, { required: true }); +export { authHandler as GET, authHandler as POST }; ``` +`withMcpAuth` hands the `AuthInfo` from `verifyToken` to the MCP server, and the toolkit passes it +to `principal` on every subscribe. With [MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks) too, call +`tasks.register(server)` in the same callback. + ### 4. Add the delivery endpoint ```ts app/api/events/route.ts @@ -87510,7 +87511,7 @@ signing and retries: subscriptions live in Upstash Redis, and every delivery goe ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server mcp-handler zod ``` ## Quickstart @@ -87519,14 +87520,25 @@ Four files in a Next.js app. ### 1. Identify the caller -Every subscription belongs to the user who subscribed. `principal` returns that user's id, read from -the token your MCP route has verified, and throws when there is none. If you also use -[MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks), both layers share this function: +Every subscription belongs to the user who subscribed. `verifyToken` turns the request's bearer token +into the SDK's `AuthInfo`, with your user id in `extra`, and `principal` reads that id back for the +toolkit. If you also use [MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks), both layers share this file: ```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). +// 1. Runs on every MCP request (wired up with withMcpAuth in step 3). +// Returning undefined answers 401. +export async function verifyToken( + req: Request, + bearerToken?: string, +): Promise { + if (!bearerToken) return undefined; + const { sub, client_id } = await verifyJwt(bearerToken); // Clerk, WorkOS, Auth0, your own + return { token: bearerToken, clientId: client_id, scopes: [], extra: { userId: sub } }; +} + +// 2. The toolkit calls this with the AuthInfo above. Return the user id, or throw. export function principal({ auth }: { auth?: AuthInfo }): string { const userId = auth?.extra?.userId; if (typeof userId !== "string") throw new Error("Not authenticated"); @@ -87559,36 +87571,26 @@ export const commentCreated = events.define("comment.created", { `authorize` is required. See [Matching and authorize](#matching-and-authorize) for when it runs. -### 3. Register the events on your MCP server - -```ts lib/server.ts -import { McpServer } from "@modelcontextprotocol/server"; -import { events } from "./events"; - -export function createServer() { - const server = new McpServer({ name: "docs", version: "1.0.0" }); - events.register(server); // adds events/list, events/subscribe and events/unsubscribe - return server; -} -``` - -Serve it from your MCP route with the verified token, as in the -[MCP Tasks quickstart](/docs/redis/sdks/agentkit/mcp-tasks#quickstart): +### 3. Serve the MCP endpoint ```ts app/api/mcp/route.ts -import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; -import { createServer } from "../../../lib/server"; +import { createMcpHandler, withMcpAuth } from "mcp-handler"; +import { verifyToken } from "../../../lib/auth"; +import { events } from "../../../lib/events"; -const handler = createMcpHandler(() => createServer()); +const handler = createMcpHandler((server) => { + events.register(server); // adds events/list, events/subscribe and events/unsubscribe +}); -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 }); -} +// Runs verifyToken before any request is handled; no AuthInfo means 401. +const authHandler = withMcpAuth(handler, verifyToken, { required: true }); +export { authHandler as GET, authHandler as POST }; ``` +`withMcpAuth` hands the `AuthInfo` from `verifyToken` to the MCP server, and the toolkit passes it +to `principal` on every subscribe. With [MCP Tasks](/docs/redis/sdks/agentkit/mcp-tasks) too, call +`tasks.register(server)` in the same callback. + ### 4. Add the delivery endpoint ```ts app/api/events/route.ts @@ -87816,7 +87818,7 @@ OpenCode and ChatGPT. No client capability is required. The toolkit sits on top TypeScript SDK and uses WebCrypto only, so it runs on Node and edge runtimes. ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server mcp-handler zod ``` ## Quickstart @@ -87825,13 +87827,27 @@ Four files in a Next.js app. ### 1. Identify the caller -Every task belongs to the user who started it. `principal` returns that user's id, read from the -token your MCP route has verified, and throws when there is none: +Every task belongs to the user who started it. Two functions, kept in one file so the path is easy +to follow: + +* `verifyToken` runs on every MCP request. It verifies the bearer token and returns the SDK's + `AuthInfo`, with your user id in `extra`. Step 3 wires it up with `withMcpAuth`. +* `principal` is what the toolkit calls. It gets that `AuthInfo` and returns the user id, or throws. ```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). +// 1. Runs on every MCP request. Returning undefined answers 401. +export async function verifyToken( + req: Request, + bearerToken?: string, +): Promise { + if (!bearerToken) return undefined; + const { sub, client_id } = await verifyJwt(bearerToken); // Clerk, WorkOS, Auth0, your own + return { token: bearerToken, clientId: client_id, scopes: [], extra: { userId: sub } }; +} + +// 2. The toolkit calls this with the AuthInfo above. Return the user id, or throw. export function principal({ auth }: { auth?: AuthInfo }): string { const userId = auth?.extra?.userId; if (typeof userId !== "string") throw new Error("Not authenticated"); @@ -87839,12 +87855,9 @@ export function principal({ auth }: { auth?: AuthInfo }): string { } ``` -See [Users](#users) for where `auth` comes from. - -### 2. Define the task layer, the task and the server +### 2. Define the task layer and the task ```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"; @@ -87865,12 +87878,6 @@ tasks.define( 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; -} ``` The handler runs later, in `/api/execute`, when QStash delivers the task, not inside the tool call. @@ -87879,20 +87886,21 @@ The handler runs later, in `/api/execute`, when QStash delivers the task, not in ### 3. Serve the MCP endpoint ```ts app/api/mcp/route.ts -import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; -import { createServer } from "../../../lib/tasks"; +import { createMcpHandler, withMcpAuth } from "mcp-handler"; +import { verifyToken } from "../../../lib/auth"; +import { tasks } from "../../../lib/tasks"; -const handler = createMcpHandler(() => createServer()); +const handler = createMcpHandler((server) => { + tasks.register(server); // adds generate_report, task_status and task_cancel +}); -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 }); -} +// Runs verifyToken before any tool sees the request; no AuthInfo means 401. +const authHandler = withMcpAuth(handler, verifyToken, { required: true }); +export { authHandler as GET, authHandler as POST }; ``` -The task layer only registers tools, so this is the SDK's own handler, unchanged. +`withMcpAuth` hands the `AuthInfo` from `verifyToken` to the MCP server, and the toolkit passes it +to `principal`. The task layer only registers tools, so nothing else changes in your route. ### 4. Add the execute endpoint @@ -88014,10 +88022,10 @@ knows who it is working for. ### Where principal comes from -`principal` receives `{ auth, request }` and may be async. `auth` is the MCP SDK's `AuthInfo`. The SDK -never fills it in from request headers: your MCP route does, after verifying the bearer token, by -passing it to `handler.fetch(request, { authInfo })` as in step 3. Put the user id in `extra` when you -build `authInfo`, and read it back in `principal`. +`principal` receives `{ auth, request }` and may be async. `auth` is exactly what `verifyToken` +returned in [step 1](#1-identify-the-caller): `withMcpAuth` runs it, and the toolkit hands the result +to `principal`. Nothing is read from request headers on its own. Without `mcp-handler`, pass the +`AuthInfo` yourself with the SDK's `createMcpHandler`: `handler.fetch(request, { authInfo })`. * **A throw refuses the call.** There is no anonymous mode: if `principal` throws, rejects, or @@ -88222,7 +88230,7 @@ OpenCode and ChatGPT. No client capability is required. The toolkit sits on top TypeScript SDK and uses WebCrypto only, so it runs on Node and edge runtimes. ```bash -npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod +npm install @upstash/mcp-toolkit @modelcontextprotocol/server mcp-handler zod ``` ## Quickstart @@ -88231,13 +88239,27 @@ Four files in a Next.js app. ### 1. Identify the caller -Every task belongs to the user who started it. `principal` returns that user's id, read from the -token your MCP route has verified, and throws when there is none: +Every task belongs to the user who started it. Two functions, kept in one file so the path is easy +to follow: + +* `verifyToken` runs on every MCP request. It verifies the bearer token and returns the SDK's + `AuthInfo`, with your user id in `extra`. Step 3 wires it up with `withMcpAuth`. +* `principal` is what the toolkit calls. It gets that `AuthInfo` and returns the user id, or throws. ```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). +// 1. Runs on every MCP request. Returning undefined answers 401. +export async function verifyToken( + req: Request, + bearerToken?: string, +): Promise { + if (!bearerToken) return undefined; + const { sub, client_id } = await verifyJwt(bearerToken); // Clerk, WorkOS, Auth0, your own + return { token: bearerToken, clientId: client_id, scopes: [], extra: { userId: sub } }; +} + +// 2. The toolkit calls this with the AuthInfo above. Return the user id, or throw. export function principal({ auth }: { auth?: AuthInfo }): string { const userId = auth?.extra?.userId; if (typeof userId !== "string") throw new Error("Not authenticated"); @@ -88245,12 +88267,9 @@ export function principal({ auth }: { auth?: AuthInfo }): string { } ``` -See [Users](#users) for where `auth` comes from. - -### 2. Define the task layer, the task and the server +### 2. Define the task layer and the task ```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"; @@ -88271,12 +88290,6 @@ tasks.define( 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; -} ``` The handler runs later, in `/api/execute`, when QStash delivers the task, not inside the tool call. @@ -88285,20 +88298,21 @@ The handler runs later, in `/api/execute`, when QStash delivers the task, not in ### 3. Serve the MCP endpoint ```ts app/api/mcp/route.ts -import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server"; -import { createServer } from "../../../lib/tasks"; +import { createMcpHandler, withMcpAuth } from "mcp-handler"; +import { verifyToken } from "../../../lib/auth"; +import { tasks } from "../../../lib/tasks"; -const handler = createMcpHandler(() => createServer()); +const handler = createMcpHandler((server) => { + tasks.register(server); // adds generate_report, task_status and task_cancel +}); -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 }); -} +// Runs verifyToken before any tool sees the request; no AuthInfo means 401. +const authHandler = withMcpAuth(handler, verifyToken, { required: true }); +export { authHandler as GET, authHandler as POST }; ``` -The task layer only registers tools, so this is the SDK's own handler, unchanged. +`withMcpAuth` hands the `AuthInfo` from `verifyToken` to the MCP server, and the toolkit passes it +to `principal`. The task layer only registers tools, so nothing else changes in your route. ### 4. Add the execute endpoint @@ -88420,10 +88434,10 @@ knows who it is working for. ### Where principal comes from -`principal` receives `{ auth, request }` and may be async. `auth` is the MCP SDK's `AuthInfo`. The SDK -never fills it in from request headers: your MCP route does, after verifying the bearer token, by -passing it to `handler.fetch(request, { authInfo })` as in step 3. Put the user id in `extra` when you -build `authInfo`, and read it back in `principal`. +`principal` receives `{ auth, request }` and may be async. `auth` is exactly what `verifyToken` +returned in [step 1](#1-identify-the-caller): `withMcpAuth` runs it, and the toolkit hands the result +to `principal`. Nothing is read from request headers on its own. Without `mcp-handler`, pass the +`AuthInfo` yourself with the SDK's `createMcpHandler`: `handler.fetch(request, { authInfo })`. * **A throw refuses the call.** There is no anonymous mode: if `principal` throws, rejects, or From 7fa4941907471ee9f672f233fb5f1ebdfcfbfb98 Mon Sep 17 00:00:00 2001 From: Cahid Arda Date: Thu, 8 Oct 2026 21:52:16 +0000 Subject: [PATCH 15/16] DX-3022: list QSTASH_URL with the required env vars Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n --- redis/sdks/agentkit/mcp-events.mdx | 2 ++ redis/sdks/agentkit/mcp-tasks.mdx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/redis/sdks/agentkit/mcp-events.mdx b/redis/sdks/agentkit/mcp-events.mdx index 45bcd398e..b12d44b4b 100644 --- a/redis/sdks/agentkit/mcp-events.mdx +++ b/redis/sdks/agentkit/mcp-events.mdx @@ -129,6 +129,8 @@ It returns `{ eventId }`. ```bash .env UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... +# Both from the QStash console. QSTASH_URL is your QStash region's URL. +QSTASH_URL=... QSTASH_TOKEN=... # Required: the delivery endpoint refuses to run without them. QSTASH_CURRENT_SIGNING_KEY=... diff --git a/redis/sdks/agentkit/mcp-tasks.mdx b/redis/sdks/agentkit/mcp-tasks.mdx index ce5f7acc8..6d740bde2 100644 --- a/redis/sdks/agentkit/mcp-tasks.mdx +++ b/redis/sdks/agentkit/mcp-tasks.mdx @@ -117,6 +117,8 @@ handler and stores the result. ```bash .env UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... +# Both from the QStash console. QSTASH_URL is your QStash region's URL. +QSTASH_URL=... QSTASH_TOKEN=... # Required: the execute endpoint refuses to run without them. QSTASH_CURRENT_SIGNING_KEY=... From 60d9821b7a7f41c5dcd9cd31200631c0965b52e2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:52:46 +0000 Subject: [PATCH 16/16] chore(llms): regenerate llms.txt and llms-full.txt --- llms-full.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/llms-full.txt b/llms-full.txt index e1948bb18..d5e53fe25 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -87308,6 +87308,8 @@ It returns `{ eventId }`. ```bash .env UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... +# Both from the QStash console. QSTASH_URL is your QStash region's URL. +QSTASH_URL=... QSTASH_TOKEN=... # Required: the delivery endpoint refuses to run without them. QSTASH_CURRENT_SIGNING_KEY=... @@ -87619,6 +87621,8 @@ It returns `{ eventId }`. ```bash .env UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... +# Both from the QStash console. QSTASH_URL is your QStash region's URL. +QSTASH_URL=... QSTASH_TOKEN=... # Required: the delivery endpoint refuses to run without them. QSTASH_CURRENT_SIGNING_KEY=... @@ -87918,6 +87922,8 @@ handler and stores the result. ```bash .env UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... +# Both from the QStash console. QSTASH_URL is your QStash region's URL. +QSTASH_URL=... QSTASH_TOKEN=... # Required: the execute endpoint refuses to run without them. QSTASH_CURRENT_SIGNING_KEY=... @@ -88330,6 +88336,8 @@ handler and stores the result. ```bash .env UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... +# Both from the QStash console. QSTASH_URL is your QStash region's URL. +QSTASH_URL=... QSTASH_TOKEN=... # Required: the execute endpoint refuses to run without them. QSTASH_CURRENT_SIGNING_KEY=...