You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.tsexportconsttasks=createTaskLayer({store: newRedisTaskStore(),dispatcher: newQStashDispatcher({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)=>{awaittask.update("Reading sources");// shows up in task_statusreturn{content: [{type: "text",text: awaitwriteReport(topic)}]};});exportfunctioncreateServer(){constserver=newMcpServer({name: "reports",version: "1.0.0"});tasks.register(server);// generate_report, task_status, task_cancelreturnserver;}
// app/api/execute/route.tsexportconstPOST=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.tsexportconstevents=createEventLayer({store: newRedisSubscriptionStore(),delivery: newQStashDelivery({url: `${process.env.APP_URL}/api/events`}),
principal,// secretKey defaults to MCP_EVENTS_SECRET_KEY});exportconstcommentCreated=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 fieldspayload: 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.
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
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
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
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
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
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
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
…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
…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
#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
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
… 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
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.
Reject null and incomplete envelopes before sending
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.
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.
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.
…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>
* 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
@upstash/mcp-toolkit, durable building blocks for MCP servers on the official TypeScript SDK (@modelcontextprotocol/serverv2):@upstash/mcp-toolkit/taskstask_status.@upstash/mcp-toolkit/events@upstash/mcp-toolkit/upstashRedisTaskStore,QStashDispatcher,WorkflowDispatcher,RedisSubscriptionStore,QStashDelivery./tasksand/eventsimport nothing from Upstash;@upstash/redis,@upstash/qstashand@upstash/workfloware regular dependencies, and@modelcontextprotocol/serveris the only peer. WebCrypto only, so it runs on Node and edge. Renamed from@upstash/mcp-tasksbefore its first release; nothing was published under the old name. Includes #60 (simplify, and fix event authorization and Workflow URL binding).Tasks
{ taskId }to the execute route; progress and the result go back to Redis, andtask_statusreturns the handler's owncontentonce completed.task_cancelsettles the taskcancelledthrough a guarded Lua script (first terminal write wins, so a late completion can't overwrite it); handlers checktask.isCancelled(). With Workflow, the run itself is cancelled too.failureFunction) settles the taskfailed.WorkflowDispatcherruns one invocation pertask.runstep, so a task can outlive the function limit. The handler gets the liveWorkflowContextmerged with the task context. Its route always runs amcp-task:startstep 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 firsttask.runnever reachedfailureFunctionand stayedworking.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
Then
events.register(server)increateServer(),export const POST = events.createDeliveryHandler()inapp/api/events/route.ts, andawait 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-32015on any failure), deterministic subscription ids that include the subscriber, TTL (7 days default, 30 max) withrefreshBefore, 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.authorizeruns 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
principalis 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).task_status/task_cancelonly accept UUIDs, and another user's task reads exactly like an unknown id. No tool lists tasks.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
tasks68,events49, telemetry 3), 16 of them against a live Upstash Redis. Typecheck, eslint and prettier are clean.pnpm e2ein 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 bothtask_statusandtask_cancel, a bad token getting 401, an unknown UUID, a throwing task endingfailed(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,authorizerefusing a user at subscribe time and again at delivery, and a host's 410 deleting the subscription.pnpm e2eafter the example build is ready but not in this branch yet: the GitHub App token used for these commits can't push workflow files.Related
Not in this PR
input_required, and listing tasks.🤖 Generated with Claude Code
https://claude.ai/code/session_01Bv5rWc6Uy6uqCwwDpMN1HJ