Skip to content

DX-3022: Add @upstash/mcp-toolkit: long-running MCP tools (tasks) and MCP Events - #38

Merged
CahidArda merged 34 commits into
mainfrom
worktree-mcp-tasks
Oct 8, 2026
Merged

CahidArda merged 34 commits into
mainfrom
worktree-mcp-tasks

Conversation

@CahidArda

@CahidArda CahidArda commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Adds @upstash/mcp-toolkit, durable building blocks for MCP servers on the official TypeScript SDK (@modelcontextprotocol/server v2):

Entry point What it gives your server Works in
@upstash/mcp-toolkit/tasks Long-running tools. A tool answers at once with a task id; the model polls task_status. Every client
@upstash/mcp-toolkit/events MCP Events. Hosts subscribe; your server sends a signed webhook when something happens. ChatGPT
@upstash/mcp-toolkit/upstash The Upstash backends for both: RedisTaskStore, QStashDispatcher, WorkflowDispatcher, RedisSubscriptionStore, QStashDelivery.

/tasks and /events import nothing from Upstash; @upstash/redis, @upstash/qstash and @upstash/workflow are regular dependencies, and @modelcontextprotocol/server is the only peer. WebCrypto only, so it runs on Node and edge. Renamed from @upstash/mcp-tasks before its first release; nothing was published under the old name. Includes #60 (simplify, and fix event authorization and Workflow URL binding).

Tasks

// lib/tasks.ts
export const tasks = createTaskLayer({
  store: new RedisTaskStore(),
  dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }),
  principal, // your user id from the verified AuthInfo; throw when there is none
});

// Module scope, so the /api/execute instance knows the handler too.
tasks.define("generate_report", { description, inputSchema: z.object({ topic: z.string() }) }, async ({ topic }, task) => {
  await task.update("Reading sources"); // shows up in task_status
  return { content: [{ type: "text", text: await writeReport(topic) }] };
});

export function createServer() {
  const server = new McpServer({ name: "reports", version: "1.0.0" });
  tasks.register(server); // generate_report, task_status, task_cancel
  return server;
}
// app/api/execute/route.ts
export const POST = tasks.createExecuteHandler(); // verifies QStash, runs the handler, 500 = retry
  • The record is written to Redis (with its TTL) before the id goes out; QStash delivers { taskId } to the execute route; progress and the result go back to Redis, and task_status returns the handler's own content once completed.
  • task_cancel settles the task cancelled through a guarded Lua script (first terminal write wins, so a late completion can't overwrite it); handlers check task.isCancelled(). With Workflow, the run itself is cancelled too.
  • After the last retry, QStash's failure callback (or Workflow's failureFunction) settles the task failed.
  • WorkflowDispatcher runs one invocation per task.run step, so a task can outlive the function limit. The handler gets the live WorkflowContext merged with the task context. Its route always runs a mcp-task:start step first: Workflow authorizes every request (the failure callback too) by running the route until its first step, so without it a handler that threw before its first task.run never reached failureFunction and stayed working.
  • The task record lives for 1 day from creation by default (defaults.ttlMs).

Why tools, not the Tasks extension: no mainstream client declares io.modelcontextprotocol/tasks (checked Codex and OpenCode source and the Claude Code changelog, 2026-10-07), and a server must not return a task to a client that didn't. The store and dispatcher don't depend on the tools, so a native adapter can serve the same records later.

Events

// lib/events.ts
export const events = createEventLayer({
  store: new RedisSubscriptionStore(),
  delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }),
  principal, // secretKey defaults to MCP_EVENTS_SECRET_KEY
});

export const commentCreated = events.define("comment.created", {
  description: "A new comment was added to a document.",
  input: z.object({ documentId: z.string().optional() }), // what a subscriber filters on; must also be payload fields
  payload: z.object({ documentId: z.string(), text: z.string() }),
  authorize: (args, { principal }) => canRead(principal, args.documentId), // required
});

Then events.register(server) in createServer(), export const POST = events.createDeliveryHandler() in app/api/events/route.ts, and await commentCreated.emit({ documentId, text }) wherever the change happens.

The layer handles events/list|subscribe|unsubscribe, a signed verification challenge before storing a subscription (one generic -32015 on any failure), deterministic subscription ids that include the subscriber, TTL (7 days default, 30 max) with refreshBefore, callback-URL checks (https, no IP literals or internal names, no redirects; DNS is not resolved, so egress filtering is still advised), whsec_ secrets sealed with AES-256-GCM, Standard Webhooks signing fresh on every attempt, and QStash retries with 410 deleting the subscription and 413 dropping the event. authorize runs at subscribe time and again before every delivery with the event's own values, so revoked access stops deliveries. Webhook mode only, following OpenAI's MCP Events guide; poll/stream modes and replay cursors are not implemented.

Security model

  • principal is required on both layers and fails closed: a throw, rejection or anything but a non-empty string refuses the call. There is no anonymous mode (() => "local" for single-user servers).
  • Tasks are owned by their caller. task_status / task_cancel only accept UUIDs, and another user's task reads exactly like an unknown id. No tool lists tasks.
  • Every QStash and Workflow delivery is verified against the configured URL (not request.url), so a signature issued for another endpoint of the same account is refused. Without signing keys the routes throw rather than run unverified.

Verified

  • 120 unit tests pass (tasks 68, events 49, telemetry 3), 16 of them against a live Upstash Redis. Typecheck, eslint and prettier are clean.
  • pnpm e2e in the demo (no model, no QStash account): starts the QStash dev server and the built app, then drives both task servers and Deploy Watch with raw JSON-RPC as a model and a host would. Checks: completion, cancel, another user's task reading as unknown for both task_status and task_cancel, a bad token getting 401, an unknown UUID, a throwing task ending failed (this caught the Workflow bug above), unsigned and forged deliveries refused (489), a filtered subscription getting exactly one validly signed webhook, another user's unsubscribe changing nothing, authorize refusing a user at subscribe time and again at delivery, and a host's 410 deleting the subscription.
  • The CI step that runs pnpm e2e after the example build is ready but not in this branch yet: the GitHub App token used for these commits can't push workflow files.
  • Both demo apps were connected to ChatGPT; see the blog post (DX-3022: blog: MCP Tasks and Events, explained upstash-web#682).

Related

Not in this PR

  • A native Tasks-extension adapter, input_required, and listing tasks.
  • Events poll/stream delivery modes and replay cursors.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ

CahidArda and others added 12 commits September 1, 2026 13:43
The 2026-07-28 MCP spec made the protocol stateless and moved long-running
tools to the Tasks extension, but the official TypeScript SDK v2 ships the
wire schemas with no runtime behind them. This adds one.

`createTaskLayer({ store, dispatcher })` turns a tool into a task-returning
tool and serves `tasks/get` / `tasks/cancel`, over two swappable interfaces:
a `TaskStore` for the record and a `TaskDispatcher` for the execution. The
split is the point — a durable task id does not make the work durable.
`@upstash/mcp-tasks/upstash` implements both on Upstash Redis (one hash per
task, PEXPIRE for TTL) and QStash (durable at-least-once delivery to an
execute endpoint), so a process killed mid-task still finishes the work.

Notable behaviour, all verified against the real SDK, real Redis and real
QStash rather than inferred:

- Terminal transitions go through a guarded, atomic `settle` (a Lua script on
  Redis), so a client's cancel and the executor completing cannot clobber each
  other; first terminal write wins.
- The store keeps one hash field per property, not one JSON blob, so a
  progress update and a cancel never overwrite each other's fields.
- `executeTask(id, { isFinalAttempt })` keeps a task `working` until the
  dispatcher's last delivery. Settling `failed` on the first error makes it
  terminal and silently turns every retry into a no-op.
- The QStash retry delay defaults to exponential backoff. A flat 1s delay
  exhausts five retries in ~10s, which a restart outlives — the task then
  dead-letters while still reading `working` (observed, then fixed).
- Clients resolve on first use, not in the constructor, so a Next.js
  production build that imports route modules without credentials still
  builds.

Two SDK gotchas are documented and worked around: `createMcpHandler` answers
`tasks/*` with -32601 before reaching a handler (hence the transport-based
route, plus a `methods` option to namespace them), and `McpServer` flattens
anything a tool callback throws into an isError result, dropping the code —
so the missing-capability refusal carries -32021 in structuredContent.

Also adds examples/mcp-tasks-demo: a Next.js app whose page is the MCP client,
showing the task lifecycle and the raw JSON-RPC wire log.

Claude-Session: https://claude.ai/code/session_01YGNUfzDFbQoteRB65VwMJU
Verifying the QStash signature, reading the task id, counting which attempt
this is, and picking the status code that decides whether QStash retries are
all facts about the transport — not about the application. So the transport
supplies the endpoint: `TaskDispatcher` gains an optional
`createExecuteHandler(run)`, surfaced as `tasks.createExecuteHandler()`, and
the demo's route collapses from ~40 lines to one. Skipping the signature check
would let anyone who can reach the route run tasks; now it cannot be skipped.

The shape is borrowed from Vercel Workflow's `Queue.createQueueHandler`, whose
`World = Storage + Queue + Streamer` is the same split as our
`TaskStore + TaskDispatcher`, and whose Upstash world makes the same two
product choices.

Status codes are the retry contract: 200 acks, 500 asks for a redelivery, and
401 (bad signature) / 400 (no task id) are deliberately terminal, because a
retry cannot fix either and a 500 there would make QStash replay an
unauthenticated request. Verification uses the published URL rather than
`request.url`, since behind a proxy the incoming URL is the internal one while
QStash signed the public destination.

Retry defaults are re-tuned around a constraint found by testing: QStash caps
`retries` per plan, and the local dev server and free tier reject anything
above 5 with `quota maxRetries exceeded` (surfaced as an isError tool result,
not a throw). The budget is therefore bought with backoff instead of attempts —
`min(pow(3, retried) * 1000, 300000)` spreads five attempts over ~2 minutes
rather than ~10 seconds. A budget shorter than a restart is exactly how a task
gets dead-lettered while still reading `working`.

Verified against live QStash: a task whose first delivery throws returns 500,
is redelivered, and completes on the second attempt.

Claude-Session: https://claude.ai/code/session_01YGNUfzDFbQoteRB65VwMJU
The ecosystem note conflated two axes, and they invert. C# ships an
IMcpTaskStore you can implement but only an in-memory implementation, so Redis
is homework. FastMCP has no implementable seam — you pick memory:// or redis://
by URL scheme — yet Redis works out of the box, and it is still the only tasks
implementation anywhere that makes the work durable rather than just the record.

Claude-Session: https://claude.ai/code/session_01YGNUfzDFbQoteRB65VwMJU
…ransport

A QStash delivery is one serverless invocation. It makes the work survive a
crash, but not exceed a time limit: pass the platform's function limit and the
invocation is killed, and because nothing recorded how far the handler got, the
redelivery restarts it from step one. For a task measured in minutes or hours
that is a livelock, not durability.

`@upstash/mcp-tasks/workflow` adds `WorkflowDispatcher`, which runs each task as
an Upstash Workflow run — one invocation per step, finished steps replayed from
a journal. `TaskContext` gains `run(stepName, fn)` and `sleep(stepName, secs)`,
which become durable checkpoints under Workflow and plain calls under a queue,
so one handler runs under either dispatcher and only its durability changes.
Verified end to end: the demo's handler was re-entered 19 times across
invocations while each step body executed exactly once.

That replay behaviour has a sharp edge worth knowing, now documented and applied
in the demo: code *outside* a step re-runs on every invocation, so side effects
(including status updates) belong inside `task.run`, while reads like
`isCancelled()` belong outside.

Retry bookkeeping also leaves the core, where it never belonged. Only the
transport knows whether it will deliver again — QStash counts deliveries,
Workflow retries per step, the inline dispatcher has no retries at all. So
`executeTask` no longer takes `isFinalAttempt` and never settles a task
`failed`; it rethrows and leaves the task `working`, and the dispatcher calls
the new `failTask` when it has actually given up. QStash learns that from its
failure callback, which fires only once every retry is exhausted and now lands
on the *same* execute endpoint — one route, one signature check, the two shapes
told apart by `sourceBody`. Workflow learns it from `failureFunction`. The
callback also carries the DLQ id and the failed response, so a failed task now
says something useful instead of just repeating the exception.

Dispatchers receive the layer's entry points through a new `attach` hook, which
removes the late-binding dance callers previously needed.

Removed: ExecuteTaskOptions, TaskRunner, isFinalQStashAttempt,
QSTASH_RETRIED_HEADER, QStashDispatcher.retries.

Both drivers pass the same end-to-end suite against live Redis and QStash with
identical handler code; the demo switches between them with TASKS_DRIVER.

Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
… in two

Backends move into `src/backends/` (redis+qstash, workflow, memory), and
`/upstash` becomes the single Upstash entry point — the standalone `/workflow`
re-export is gone.

The bigger change is that transports are no longer pretended to be
interchangeable. `TaskDispatcher<TContext>` declares what it gives a running
handler, and that flows through `createTaskLayer` into `registerTask`: a
queue-backed layer hands the handler a `TaskContext`, while
`createTaskLayer<WorkflowContext>` hands it `TaskContext & WorkflowContext` —
one object with both `update`/`isCancelled` and the engine's real `run`,
`sleep`, `call`, `waitForEvent`. That replaces the previous `TaskSteps` shim,
which offered two methods that quietly did nothing useful on a queue. The
compiler now rejects a workflow handler wired to a transport that cannot run it.

The context is merged onto the engine's object rather than spread into a new
one, because a WorkflowContext keeps its methods on the prototype; a test pins
that, since a spread would compile fine and fail only against a real workflow.

Two things the live runs surfaced, both fixed at the root:

- `TaskStore.update` now no-ops on a terminal task, on both backends. The
  spec's "state does not change" covers the status message, and a write landing
  after a cancel was replacing "Cancelled by client" with an error string. The
  Redis path does it with the same Lua guard `settle` already used, which also
  makes update cheaper (one round trip instead of three).
- `executeTask` no longer records anything when the handler throws. The core
  cannot tell a real failure from a workflow engine suspending the handler
  mid-step, and it was writing "attempt failed" over healthy runs.

The SDK also journals its own writes now: under a workflow, `task.update(...)`
runs once instead of on every replay, and users do not wrap it themselves.
Journaling is skipped when already inside a step, since the engine rejects
nested steps. `isCancelled` is deliberately not journaled — it must read live
state, or a cancel arriving later would never be seen.

The demo is now two servers rather than one env switch, because the handlers
genuinely differ: `/api/mcp` + `/api/execute` on QStash, `/api/mcp-workflow` +
`/api/execute-workflow` on Workflow, with a driver picker in the UI. Both pass
the same end-to-end suite against live Redis and QStash.

Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
…bility

The usage section is now the smallest thing that conveys the idea — no optional
parameters in the main snippet — and everything else moves into toggles: the
Store and Dispatcher interfaces, the options tables, sequence diagrams for
tools/call, execution and tasks/get + tasks/cancel showing which layer owns
what, and an FAQ.

The FAQ answers the questions this package actually raises: what the execute
endpoint does on your behalf, why it serves through the transport rather than
the SDK's createMcpHandler, why a missing capability arrives as a structured
tool error instead of -32021, how long retries last and what happens when they
run out, and whether a task id is a secret.

Adds an mcp-handler section. It wraps the SDK's own createMcpHandler, so
`tasks/get` and `tasks/cancel` come back -32601 before the handler is looked up
— but task *creation* works untouched, and renaming the two methods via the
`methods` option makes the rest dispatch. Verified by running the README's
snippet against the real packages: tools/call returns a handle and
`upstash/tasks.get` polls it to completed.

Also drops the two Vercel Workflow comparisons from the retry docs.

Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
Progress reporting and cancellation are opt-in, so they move out of the opening
example into a toggle. What is left is the minimum a working server needs.

Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
The endpoint belongs to the dispatcher, so what it does differs by dispatcher —
but the FAQ answered only for QStash while claiming to describe it generally.
Signatures, failure callbacks and retry status codes are QStash's; Workflow
serves the engine's own handler and owns authentication and replay itself; an
in-process dispatcher has no endpoint at all. The execution flow diagram said
'verify signature' for the same reason and now says the transport authenticates
the delivery.

Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
…e intro

The README explained what the package does but not the thing an SDK maintainer
would actually want to know: what of this belongs upstream.

It separates the two levels. Almost all of it can live outside the SDK — this
package is additive over @modelcontextprotocol/server, which is itself the
finding. Two things cannot: tasks/get and tasks/cancel are undispatchable on the
2026-07-28 era (in the 2025 registry, dropped from 2026, so the gate answers
-32601 before handler lookup), and a tool callback cannot return a JSON-RPC
error, which makes the spec's -32021 for a missing tasks capability unreachable.
Both workarounds for the first are spelled out along with what each costs.

Then the design point, if a runtime does ship: two interfaces rather than one.
The store half already has precedent in C#'s IMcpTaskStore; the dispatcher half
exists in no official SDK, which is why every one of them ends up with a durable
record and non-durable work — fine on a long-lived host, fatal on serverless.

Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
…ning the callback endpoint

'The SDK' was ambiguous throughout a section aimed at SDK maintainers, and in
one place it read as though @modelcontextprotocol/server journalled our writes
when the journalling is ours. Everything is now named, and the section states
which version the findings were verified against.

The callback endpoint is promoted from an aside to its own point. Once the work
runs outside the request something has to call back in, so a task server needs a
second route the spec never describes — and every serverless implementation
reinvents it along with the delicate parts: authenticating the caller, telling a
delivery from a failure notification, and picking the status code that decides
whether the transport retries. That is transport knowledge, not application
knowledge, so the runtime should hand back a finished endpoint. It also notes
this leaves single-endpoint servers possible, since the transport authenticates
its own deliveries.

Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
…sise the shape

The endpoint had ended up inside the speculative 'if a runtime ships' part,
where it read as design preference. It belongs with the other findings: every
task server needs a second route the spec never describes, and each one
re-implements authenticating the caller, telling a delivery from a failure
notification, and picking the retry status code.

'Two things only @modelcontextprotocol/server can fix' no longer fits, since
this package does implement the third — so the heading is now 'Three gaps',
with a line separating the two nobody can work around from the one everybody
re-solves, where a mistake is a security bug rather than a missing feature.

The remaining design suggestion is collapsed into a toggle and trimmed to the
two interfaces, so the section leads with what was observed rather than what we
would prefer.

Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
…tension

No mainstream client declares io.modelcontextprotocol/tasks (Claude Code,
Codex, Cursor and OpenCode all checked on 2026-10-07), and a server must not
return a task to a client that did not. So the native surface served nobody,
while costing three SDK workarounds: the -32601 gate on tasks/*, the renamed
methods, and -32021 smuggled through structuredContent.

A task tool now answers with an ordinary tool result: the task object in
structuredContent and a sentence telling the model to poll. Two shared tools,
task_status and task_cancel, are registered once per server; a completed
task_status returns the handler's own content, as if the call had run
synchronously. The layer needs nothing from the SDK beyond registerTool, so
the demo serves through createMcpHandler unchanged and serve-mcp.ts is gone.

The store, the dispatchers and the execute endpoint are untouched.

New, because plain tools are called freely by the model:
- principal: scopes tasks to their caller via ctx.http.authInfo; another
  caller's task reads as unknown.
- idempotencyKey: a retried start with the same key returns the existing
  task (id = sha256 of owner, tool, key).
- executeTask on a missing task returns null (acked) instead of throwing.

Removed: TASKS_EXTENSION, TASKS_PROTOCOL_VERSION, TASK_METHODS, the methods
and onMissingCapability options, and the capability check.

Verified: 51 tests including live Redis, and the smoke script end to end
against both demo servers (QStash and Workflow) on the local QStash dev server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd
@CahidArda CahidArda changed the title Add @upstash/mcp-tasks: a durable MCP Tasks runtime Add @upstash/mcp-tasks: durable long-running tools for MCP servers Oct 7, 2026
@CahidArda CahidArda changed the title Add @upstash/mcp-tasks: durable long-running tools for MCP servers DX-3022: Add @upstash/mcp-tasks: durable long-running tools for MCP servers Oct 7, 2026
@linear-code

linear-code Bot commented Oct 7, 2026

Copy link
Copy Markdown

DX-3022

CahidArda and others added 3 commits October 7, 2026 12:11
Conflicts were both-sides additions: the README and CLAUDE.md example lists,
and scripts/sync-version.mjs targets (tanstack-ai from main, mcp-tasks from
here) all keep both entries. pnpm-lock.yaml is main's, regenerated.

main's lockfile resolves @modelcontextprotocol/server to 2.3.1, which refuses
to reuse a stateless transport across requests. core.test.ts reused one, so
it now serves through createMcpHandler with a fresh server per request, the
same path the demo uses. No library code changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd
The package has never been published, but its three changesets described
an evolution of APIs this PR has since removed (tasks/get, TaskSteps,
isFinalAttempt). Replace them with one changeset describing the package as
it ships, and start from 0.0.0 so the first release is 0.1.0, not 0.2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd
The execute route looks a handler up by name, so it only knows handlers that
registerTask has run for in that process. The demo already calls createServer()
at module scope; the README example did not, so a cold execute instance would
fail with "No task handler registered".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd
The package becomes @upstash/mcp-toolkit with two entry points:

- /tasks (and /tasks/upstash): the long-running tools, unchanged apart
  from a new onSettle hook that fires once per finished task.
- /events (and /events/upstash): MCP Events with webhook delivery, as
  ChatGPT ships it. Typed define()/emit(), events/list|subscribe|
  unsubscribe, signed verification challenge, deterministic ids,
  expiry/refresh, SSRF checks, secrets encrypted at rest, Redis
  subscriptions and QStash-retried Standard Webhooks deliveries.

taskFinishedEvent() bridges the two. The demo adds a task.finished
event and a local receiver; the smoke test checks a signed delivery end
to end through the QStash dev server. Lua scripts now carry
allow-key-locking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd
@CahidArda CahidArda changed the title DX-3022: Add @upstash/mcp-tasks: durable long-running tools for MCP servers DX-3022: Add @upstash/mcp-toolkit: long-running MCP tools (tasks) and MCP Events Oct 7, 2026
CahidArda and others added 4 commits October 8, 2026 07:24
Drop the /events/upstash entry point: RedisSubscriptionStore and
QStashDelivery now come from @upstash/mcp-toolkit/events directly.
/tasks/upstash stays as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd
Report Desk (/api/mcp) is tasks only: generate_report with a canned
multi-line report, task_status and task_cancel. Deploy Watch
(/api/deploy-watch) is events only: a deploy.finished event filterable
by service and environment, a list_recent_deploys tool, and a route that
reports a deploy. The smoke test checks that a staging deploy is filtered
out and a production deploy arrives signed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK
API
- tasks.define() at module scope + tasks.register(server) per request, replacing
  registerTask(); a cold instance serving only the execute route has every handler
- principal is required on both layers and fails closed
- every emit names its recipients (owner or owners); the subscription index is per
  (event, owner, argsKey), so a {} subscription only hears about its own user
- smaller surface: TaskLayer is { define, register, createExecuteHandler, getTask,
  cancelTask }; EventLayer is { define, register, createDeliveryHandler, list };
  dispatchers get run/fail via attach; store/client overload constructors removed

Security
- callback URLs: trailing dots stripped; IPv4 embedded in IPv6 (mapped, compatible,
  NAT64, 6to4), 198.18/15, 192.0.0/24 and documentation ranges refused
- every failed verification answers the same -32015 (detail logged), and at most
  4 KB of the challenge answer is read
- signing keys required for QStashDispatcher, WorkflowDispatcher and QStashDelivery;
  Workflow gets an explicit Receiver and the public url
- bad signatures and bodies answer 489 + Upstash-NonRetryable-Error (QStash retries
  every other non-2xx); missing keys throw instead of looking like a bad signature
- no default MCP_EVENTS_SECRET_KEY, demo included

Correctness
- create-if-absent task creation in one Lua script; update/settle share one guarded
  script returning [changed, ...record]; settle returns { task, settled }
- a failed dispatch settles the task failed instead of leaving it working
- dispatch dedupes on dispatchKey(task) = taskId + createdAt, since QStash keeps
  dedup ids for 10 minutes and Workflow refuses reused run ids
- handler errors are logged; failure bodies decode as UTF-8; redirects are dropped,
  not retried; delivery response bodies are released; ttlMs: null is kept
- WebCrypto only, so the package runs on edge runtimes
- QStash and Workflow clients tagged with @upstash/mcp-toolkit telemetry
- shared client/auth/crypto helpers; comments cut to the essentials

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK
CahidArda and others added 2 commits October 8, 2026 11:54
…ion lookups

- task.finished uses evt_task_${dispatchKey(task)}: a keyed task re-created after
  its record expires reuses the task id, and QStash (10-minute dedup window) or a
  host deduping on webhook-id would drop the second task's event
- RedisSubscriptionStore.find splits its ZRANGE pipeline and MGET into batches of
  at most 1,000 commands, so emit({ owners }) with a large team stays bounded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK
… async

- principal gets the verified AuthInfo and the raw request (SDK v2's
  ctx.http.authInfo and ctx.http.req), and may return a promise, so apps that
  authenticate with a cookie or session can look it up
- authorize receives { principal, auth, request }
- README explains what AuthInfo is and that the app's route populates it via
  handler.fetch(request, { authInfo }); the SDK never derives it from headers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK
CahidArda and others added 5 commits October 8, 2026 12:18
…to`, typed args

- authorize is required on every event and runs again before each delivery
  (phase "deliver"), so revoked access stops events; refused deliveries are dropped
- emit no longer needs recipients: matching subscriptions that authorize allows get
  it; `to` optionally narrows it, and `personal: true` events require `to` (type and
  runtime). task.finished is personal, emitted to the task's owner
- principal may return { id, context }: non-secret context (<= 4 KB) is stored on the
  subscription and handed back to authorize at delivery. AuthInfo is never stored
- emit's `args` is optional only when the payload carries every input field;
  otherwise the type requires it, and a missing required input value throws
- the stored field is `subscriber`; the Redis index is per (event, argsKey) again
- tests: readers subscribe, a non-reader is refused, one emit reaches each reader;
  revocation, context at delivery, personal events, typed args (@ts-expect-error)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK
Every key the toolkit writes, what it is for, and what it means for security
(plaintext args/results, encrypted signing secrets, no tokens stored, owner and
subscriber checks) and correctness (create-if-absent, guarded terminal writes, TTLs,
index expiry, deterministic ids, bounded lookups). Also what sits in QStash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK
Why a user can't read or cancel another user's task, why only users with access
can subscribe, and how authorize is re-checked before every delivery. Plus what
the toolkit does not cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5x4btgiRE3FKdpUW527Wu
…, drop idempotencyKey

- /tasks and /events are generic (layers, interfaces, memory backends); every Upstash
  backend for both is in @upstash/mcp-toolkit/upstash. /tasks/upstash is gone.
- @upstash/redis, @upstash/qstash and @upstash/workflow are regular dependencies, so
  the package works without extra installs; only the MCP SDK stays a peer.
- principal is typed Principal | Promise<Principal>: it throws to refuse. The layers
  still fail closed on a rejection or an answer without a non-empty string id.
- idempotencyKey removed (add back if someone asks): task ids are always random, so
  dispatches and task.finished dedupe on the task id and dispatchKey is gone.
- The Workflow context is inferred from the dispatcher; no createTaskLayer<WorkflowContext>.
- README: quickstart shows task.update / isCancelled inline, a full Workflow task
  example, and the new imports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5x4btgiRE3FKdpUW527Wu
CahidArda and others added 3 commits October 8, 2026 13:18
#60)

* DX-3022: mcp-toolkit: simplify, and fix event authorization and Workflow signature checks

Security:
- WorkflowDispatcher now binds QStash signatures to its URL. Workflow's serve()
  verifies only body and signature, so a signature issued for any other endpoint
  of the account was accepted and the task ran (reverting the fix makes the new
  test fail with the task executed).
- emit(payload) is the only form: an event's values come from the payload, and
  the same values route it and authorize it. Delivery-time authorize gets the
  event's values instead of the subscription's own (possibly empty) filter, so a
  subscriber with no filter can no longer receive every document's events.
- Task ids are validated as UUIDs; subscribe identifies the caller before
  validating anything; the events secretKey must be >= 32 random bytes; every IP
  literal is refused as a callback host.
- Signature tests use a real QStash Receiver with real JWTs (wrong key, wrong
  URL, wrong body, expired) instead of a stubbed verify.

Simplified:
- Removed personal/to/match/explicit args, principal context, taskFinishedEvent
  and onSettle, toolNames, queuedMessage, per-tool ttl overrides, ttlMs: null,
  getTask/cancelTask, dispatchId, UnknownTaskError, input_required, the events
  fetch/timeoutMs/defaults options and per-backend headers.
- Dispatchers and deliveries receive their entry points in createExecuteHandler /
  createDeliveryHandler instead of attach().
- One subscription index per event with matching in JS, instead of up to 256
  hashed subset indexes; plain MULTI writes instead of the create and put Lua
  scripts (the cancel-vs-complete guard stays).
- Memory backends moved to test-support (not shipped); public exports trimmed.
- README rewritten: minimal snippets, details in toggles.

Claude-Session: https://claude.ai/code/session_012geGTRLRnPqYtaASmBdhE1

* DX-3022: mcp-toolkit README: type authInfo as the SDK's AuthInfo

Claude-Session: https://claude.ai/code/session_012geGTRLRnPqYtaASmBdhE1

* DX-3022: mcp-toolkit: type principal's auth as the SDK's AuthInfo

Drops the structural CallerAuth copy; Caller.auth and AuthorizeCaller.auth are
the SDK's own AuthInfo, and the README's principal takes it from the SDK too.

Claude-Session: https://claude.ai/code/session_012geGTRLRnPqYtaASmBdhE1
The TTL counts from creation and is never extended, so the old 5-minute
default let a slow task plus its QStash retries expire mid-run and read
as "unknown task". The demo servers now use the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ
CahidArda added a commit to upstash/docs that referenced this pull request Oct 8, 2026
Follows upstash/agentkit#38, where the default task TTL went from 5 minutes to 1 day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ
CahidArda added a commit to upstash/upstash-web that referenced this pull request Oct 8, 2026
Follows upstash/agentkit#38, where the default task TTL went from 5 minutes to 1 day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ
CahidArda and others added 2 commits October 8, 2026 15:26
Workflow authorizes every request, the failure callback included, by running
the route function until its first step, and refuses one that throws or
returns before any. A handler that threw before its first task.run never
reached failureFunction, so the task read `working` until its TTL. The route
now runs a `mcp-task:start` step first. Caught by the new e2e failure check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ
- Demo auth: `Bearer demo-<name>` is the user `demo-<name>`, passed to the SDK
  as authInfo (no header = `demo-user`, so the page and ChatGPT keep working).
- Deploy Watch's authorize keeps production deploys from `demo-intern`.
- `pnpm e2e` starts the QStash dev server and the built app and runs
  scripts/smoke.mjs against both task servers. New checks: cross-user
  isolation, 401, unknown UUID, the failure path, unsigned/forged deliveries,
  per-user subscriptions, authorize at delivery, 410 deleting a subscription.
- The CI step that runs it is in a separate change (the GitHub App token here
  cannot push workflow files).
- Cleanup: drop the unused TASKS_DRIVER, input_required and nullable ttlMs,
  and fix README claims about cancel and dead-lettered tasks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Event deduplication collisions, invalid task-default handling, schema normalization, and dependency-floor issues can cause incorrect runtime behavior.

7 open findings
What changed in this PR

Adds @upstash/mcp-toolkit, providing durable MCP task execution and webhook events backed by Redis, QStash, and Workflow.

Changes:

  • Adds task and event layers with authentication, authorization, signing, retries, and durable storage.
  • Adds QStash/Workflow backends with comprehensive tests.
  • Adds a Next.js demo and documentation covering both features.
File Description
.changeset/​mcp-toolkit-initial.md Records the initial package release.
README.md Documents the new toolkit and demo.
scripts/​sync-version.mjs Adds toolkit version synchronization.
packages/​mcp-toolkit/​package.json Defines package exports and dependencies.
packages/​mcp-toolkit/​README.md Documents tasks, events, security, and backends.
packages/​mcp-toolkit/​tsconfig.json Configures TypeScript compilation.
packages/​mcp-toolkit/​tsup.config.ts Builds the three package entry points.
packages/​mcp-toolkit/​src/​version.ts Provides the generated package version.
packages/​mcp-toolkit/​src/​upstash.ts Exports all Upstash backends.
packages/​mcp-toolkit/​src/​telemetry.ts Adds Redis and QStash telemetry tags.
packages/​mcp-toolkit/​src/​telemetry.test.ts Tests telemetry behavior and opt-outs.
packages/​mcp-toolkit/​src/​test-support.ts Provides test-only stores and dispatchers.
packages/​mcp-toolkit/​src/​shared/​auth.ts Resolves authenticated principals.
packages/​mcp-toolkit/​src/​shared/​clients.ts Lazily creates and verifies Upstash clients.
packages/​mcp-toolkit/​src/​shared/​crypto.ts Provides runtime-neutral cryptographic helpers.
packages/​mcp-toolkit/​src/​shared/​env.ts Provides dependency-free environment helpers.
packages/​mcp-toolkit/​src/​tasks/​index.ts Exposes the tasks public API.
packages/​mcp-toolkit/​src/​tasks/​types.ts Defines task backend contracts and records.
packages/​mcp-toolkit/​src/​tasks/​core.ts Implements task tools and lifecycle handling.
packages/​mcp-toolkit/​src/​tasks/​core.test.ts Tests task lifecycle, ownership, and retries.
packages/​mcp-toolkit/​src/​tasks/​backends/​qstash.ts Implements Redis storage and QStash dispatch.
packages/​mcp-toolkit/​src/​tasks/​backends/​qstash.test.ts Tests Redis and QStash task behavior.
packages/​mcp-toolkit/​src/​tasks/​backends/​workflow.ts Implements Workflow-based task execution.
packages/​mcp-toolkit/​src/​tasks/​backends/​workflow.test.ts Tests Workflow execution and verification.
packages/​mcp-toolkit/​src/​events/​index.ts Exposes the events public API.
packages/​mcp-toolkit/​src/​events/​types.ts Defines event backend contracts.
packages/​mcp-toolkit/​src/​events/​core.ts Implements subscriptions, authorization, and emits.
packages/​mcp-toolkit/​src/​events/​core.test.ts Tests event security and delivery behavior.
packages/​mcp-toolkit/​src/​events/​webhooks.ts Implements signing, encryption, and URL checks.
packages/​mcp-toolkit/​src/​events/​backends/​qstash.ts Implements Redis subscriptions and QStash delivery.
packages/​mcp-toolkit/​src/​events/​backends/​qstash.test.ts Tests event persistence and delivery.
examples/​mcp-toolkit-demo/​.env.example Documents required demo configuration.
examples/​mcp-toolkit-demo/​.gitignore Excludes generated and secret files.
examples/​mcp-toolkit-demo/​README.md Documents running and testing the demo.
examples/​mcp-toolkit-demo/​package.json Defines demo dependencies and scripts.
examples/​mcp-toolkit-demo/​tsconfig.json Configures demo TypeScript.
examples/​mcp-toolkit-demo/​next.config.ts Configures Next.js.
examples/​mcp-toolkit-demo/​next-env.d.ts Adds Next.js type references.
examples/​mcp-toolkit-demo/​scripts/​e2e.mjs Starts services and runs end-to-end checks.
examples/​mcp-toolkit-demo/​scripts/​smoke.mjs Exercises tasks and events over JSON-RPC.
examples/​mcp-toolkit-demo/​app/​layout.tsx Defines demo metadata and layout.
examples/​mcp-toolkit-demo/​app/​page.tsx Implements the task client UI.
examples/​mcp-toolkit-demo/​app/​globals.css Styles the demo interface.
examples/​mcp-toolkit-demo/​app/​lib/​auth.ts Implements demo authentication.
examples/​mcp-toolkit-demo/​app/​lib/​deploy-watch.ts Defines the event demonstration server.
examples/​mcp-toolkit-demo/​app/​lib/​e2e.ts Adds test-only failure hooks.
examples/​mcp-toolkit-demo/​app/​lib/​mcp-client.ts Implements the browser JSON-RPC client.
examples/​mcp-toolkit-demo/​app/​lib/​qstash-server.ts Defines the QStash task server.
examples/​mcp-toolkit-demo/​app/​lib/​workflow-server.ts Defines the Workflow task server.
examples/​mcp-toolkit-demo/​app/​api/​mcp/​route.ts Serves the QStash MCP endpoint.
examples/​mcp-toolkit-demo/​app/​api/​mcp-workflow/​route.ts Serves the Workflow MCP endpoint.
examples/​mcp-toolkit-demo/​app/​api/​execute/​route.ts Receives QStash task deliveries.
examples/​mcp-toolkit-demo/​app/​api/​execute-workflow/​route.ts Receives Workflow task deliveries.
examples/​mcp-toolkit-demo/​app/​api/​deploy-watch/​route.ts Serves the events MCP endpoint.
examples/​mcp-toolkit-demo/​app/​api/​deploy-watch/​events/​route.ts Receives event delivery jobs.
examples/​mcp-toolkit-demo/​app/​api/​deploy-watch/​deploys/​route.ts Emits demo deployment events.
examples/​mcp-toolkit-demo/​app/​api/​receiver/​route.ts Implements the demo webhook receiver.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/mcp-toolkit/src/events/backends/qstash.ts Outdated
Comment thread examples/mcp-toolkit-demo/app/page.tsx
Comment thread packages/mcp-toolkit/package.json Outdated
Comment thread packages/mcp-toolkit/src/events/core.ts Outdated
Comment thread packages/mcp-toolkit/src/tasks/core.ts Outdated
Comment thread examples/mcp-toolkit-demo/README.md
Comment thread examples/mcp-toolkit-demo/scripts/e2e.mjs
CahidArda and others added 2 commits October 8, 2026 23:55
… review fixes

- Tasks: `task.principal` (the owner) in TaskContext; optional `authorize(args, { principal, auth, request })` on tasks.define, run before the task is stored or queued
- Tasks: reject non-positive / non-integer `defaults.ttlMs` and `pollIntervalMs`
- Events: `maxSubscriptions` (default 8, Infinity = off) live subscriptions per subscriber, checked before the callback challenge and atomically in the store (Lua over idx:<event> and by:<subscriber>); SubscriptionStore gains count(), put() takes { limit } and returns boolean, delete() takes subscriber
- Events: route and authorize deliveries by the payload's input fields parsed with `input`, so transforms apply on both sides; a payload that doesn't fit `input` makes emit throw
- Drop QStash deduplicationId for tasks and events (Copilot: the sanitized event id was lossy); the event id still reaches the host as webhook-id
- @upstash/redis floor ^1.38.4; aria-pressed on the demo's transport toggle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Event identifiers and delivery bodies need stricter validation, alongside correcting inaccurate documentation and dependency alignment.

0 open findings

7 resolved since last review
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Previously missed (4)

In code that hasn't changed since last review

Medium severity Raise the demo Redis dependency floor to 1.38.4

examples/​mcp-toolkit-demo/​package.json:17

This is the only workspace dependency still permitting Redis 1.38.0, while the toolkit and the rest of the repository establish ^1.38.4 as the floor (packages/mcp-toolkit/package.json:56) because earlier clients can send the read-your-writes sync token one request late. Align the demo so an independently resolved direct import cannot regress to the affected versions.

Medium severity Reject null and incomplete envelopes before sending

packages/​mcp-toolkit/​src/​events/​backends/​qstash.ts:217

This guard accepts null or any object as an envelope. For a signed job targeting an existing subscription, { envelope: null } then throws in send, and incomplete objects can reach authorization or webhook delivery instead of receiving the documented non-retryable 489 response. Validate the envelope fields before calling send.

Medium severity Validate custom eventId before building the webhook envelope

packages/​mcp-toolkit/​src/​events/​core.ts:217

A custom eventId is later copied directly into the webhook-id header, but this API accepts empty or header-invalid values. An empty id cannot be verified by the exported verifyWebhook, while control characters make postSigned throw on every delivery attempt; emit still queues the job and reports success. Validate the id before building the envelope.

Low severity Document or remove the unsupported task completion event claim

README.md:40

This says task completion is an MCP Event, but the new task and event layers are independent: there is no task-finished event or settlement hook. Remove that claim or document how an application explicitly emits its own event after task work.

🧠 Review effort: Balanced

@CahidArda
CahidArda merged commit 5644daa into main Oct 8, 2026
2 of 3 checks passed
CahidArda added a commit to upstash/docs that referenced this pull request Oct 8, 2026
…AgentKit and the QStash TS SDK (#901)

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd

* DX-3022: docs: single /events import, users and subscriptions section, Deploy Watch example

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5x4btgiRE3FKdpUW527Wu

* 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

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ

* chore(llms): regenerate llms.txt and llms-full.txt

* 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:<subscriber> key; SubscriptionStore interface

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n

* chore(llms): regenerate llms.txt and llms-full.txt

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n

* chore(llms): regenerate llms.txt and llms-full.txt

* DX-3022: list QSTASH_URL with the required env vars

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n

* chore(llms): regenerate llms.txt and llms-full.txt

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
enesakar pushed a commit to upstash/upstash-web that referenced this pull request Oct 9, 2026
* blog: MCP Tasks and Events, explained

How the MCP Tasks extension and the draft MCP Events spec work, with three
diagrams, where client support stands as of 2026-10-07, and how
@upstash/mcp-tasks ships durable long-running tools as plain MCP tools today.
Also covers the path to native Tasks and where Cloudflare's stack fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd

* DX-3022: blog: link the MCP Tasks docs page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd

* DX-3022: blog: @upstash/mcp-toolkit, events section, server support

Rename the package to @upstash/mcp-toolkit, add an Events section
with /events and the task.finished bridge, and say which servers emit
MCP Events today alongside the client table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd

* DX-3022: blog: diagram of how Redis and QStash power MCP Events

Adds upstash-events.svg to the events section, a short note on why
deliveries go through a route instead of QStash calling the host, the
single @upstash/mcp-toolkit/events import, and fixes the stale package
name in the tasks diagram title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd

* DX-3022: blog: describe MCP Events as a design sketch, not a spec

The Triggers & Events WG has a design sketch but no SEP yet (charter
still lists it as ideating). Say that, and that ChatGPT shipped the
webhook part of the sketch ahead of the standard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt2iNfw9zcDS5T2bBgquUd

* DX-3022: blog: add ChatGPT test notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK

* DX-3022: blog: address the review

- snippets match the toolkit: tasks.define/register, required principal,
  secretKey, owner-scoped emit, McpServer visible, the execute route's import,
  and one coherent task.finished example
- say the toolkit sits on the official MCP TypeScript SDK; add install and env vars
- fix claims: QStash work is bound by the function limit, Workflow needs three
  changes (type argument, steps, longer ttlMs), task_status prepends a status line,
  a refresh with the same secret skips the challenge, task.finished with ChatGPT is
  untested, host-dependent model visibility hedged, tasks/update mentioned
- security notes: principal needs auth middleware (not clientId); callback checks
  don't resolve DNS; demo endpoint unauthenticated on purpose
- trims: one support table plus one sentence, one webhooks explanation, the native
  path and Cloudflare merged, shorter label note
- events diagram legend: 410 deleted, 413 dropped, other errors retried; titles on
  the SVGs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK

* DX-3022: blog: principal receives { auth, request }

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK

* DX-3022: blog: emit without recipients, authorize re-checked at delivery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWZjexwCU7VKGGrJ3oxrKK

* DX-3022: blog: /upstash imports, principal throws to refuse, drop idempotencyKey

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5x4btgiRE3FKdpUW527Wu

* DX-3022: blog: redesign the MCP Tasks and Events diagrams

- One palette across all four, from the site's emerald/zinc tokens, with
  a role color per participant (host, your server, Redis, QStash, app).
- Light and dark mode: each SVG carries a prefers-color-scheme block, so
  the diagrams no longer render as a bright white card on the dark site.
- Phone variants (360 wide) served through <picture>, so the text stays
  readable at phone width instead of shrinking to ~5px.
- Sequence diagrams use proper notation: a loop fragment for polling, an
  activation bar for background work, a dashed reply for the challenge
  echo, and "subscribe once" / "every time" phases.
- Architecture diagrams use orthogonal arrows with numbered badges and a
  numbered legend; adds the read arrow from Redis to task_status.
- <title>/<desc> in every SVG, plus width/height on the img to avoid
  layout shift.

Claude-Session: https://claude.ai/code/session_012geGTRLRnPqYtaASmBdhE1

* DX-3022: blog: match the simplified mcp-toolkit API

Drop the task.finished / onSettle bridge and personal events with `to`,
explain that input fields are payload fields and that authorize is checked
against each event's values, note that IP callback hosts are refused and the
secret key needs 32+ random bytes, and add zod to the install line.

Claude-Session: https://claude.ai/code/session_012geGTRLRnPqYtaASmBdhE1

* DX-3022: blog: 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ

* DX-3022: blog: task authorize + task.principal, subscription limit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n

* DX-3022: blog: show where AuthInfo is built (mcp-handler withMcpAuth)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n

* DX-3022: blog: drop the Cloudflare mention

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n

* DX-3022: blog: publish date 2026-10-09

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n

* DX-3022: list QSTASH_URL with the required env vars

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n

* DX-3022: blog: why Redis and QStash for events on serverless; native path under Tasks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n

* DX-3022: blog: shorter paragraphs, leave the details to the docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n

* DX-3022: blog: drop the env var list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gtucop3k8CNVLjgvoDTC8n

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants