MCP Tasks and Events, Explained: Long-Running Tools and Agents That Wake Up
Agents built on MCP have two blind spots.
The first is time. A tool call is a request that waits for a response. If the work takes a few minutes, something gives up first: the client's tool timeout, your serverless function's limit, or the user's patience.
The second is initiative. Most agents only act when someone messages them or a cron job fires. Nothing tells them "the thing you were waiting for just happened."
MCP now has an answer to each: Tasks for work that takes a while, and Events for waking an agent up. This post explains how both work, where support stands as of October 2026, and how we're shipping both today with @upstash/mcp-toolkit.
MCP Tasks: answer now, finish later
Tasks shipped with the 2026-07-28 MCP specification as an official extension, io.modelcontextprotocol/tasks.
Instead of making the client wait, a server answers a tools/call right away with a task handle. The work continues in the background, and the client polls tasks/get until the task is done.
A few details make this work:
- The client opts in. It declares the tasks extension in its capabilities. A server must never hand a task to a client that didn't, because that client wouldn't know what to do with it.
- The server sets the pace. Each task carries a
pollIntervalMs, so the client knows how often to ask. - States are simple. A task is
working,input_required,completed,failedorcancelled, and the last three are final. - The client polls, not the model. A host can keep the polling out of the model's context, so a long job doesn't cost it extra turns.
MCP Events: the server wakes the agent
Events are newer and not yet a spec. The design lives as a sketch in the incubation repo of the MCP Triggers & Events Working Group, and there's no formal proposal (SEP) yet. OpenAI shipped the webhook part of that sketch in ChatGPT anyway, documented in their MCP Events guide.
The idea: the user tells the agent what to watch ("new review comments on this doc") and what to do about it. The host subscribes through your MCP server and hands over a callback URL and a signing secret. When something matching happens, your server POSTs a signed event to that URL, and the agent wakes up and acts.
What a server has to implement:
events/list,events/subscribeandevents/unsubscribe, on the same endpoint as your tools.- A signed challenge to verify a callback URL before sending it real data.
- Deliveries signed with Standard Webhooks and retried with backoff.
- Subscription storage that survives restarts, with an expiry the host refreshes.
Tasks vs Events
They solve opposite problems, and they compose well.
| Tasks | Events | |
|---|---|---|
| Who starts it | The client calls a tool | Something happens in the world |
| How many results | One call, one result | Zero to many deliveries |
| Lifetime | Ends in a final state | Open-ended, refreshed until cancelled |
| Direction | The client pulls (tasks/get) | The server pushes (webhook) |
| Typical use | Reports, crawls, migrations, builds | New messages, comments, failed jobs |
A task answers "I asked, tell me when it's done." An event answers "wake me when X happens."
Who supports what today
This is the catch. As of October 7, 2026:
| Client | Tasks | Events |
|---|---|---|
| ChatGPT | ❌ (doesn't declare it, see our test) | ✅ Webhooks, in Work chats |
| Codex | ❌ (feature request) | Only for OpenAI's own connectors |
| Claude Code | ❌ (feature request) | ❌ |
| Cursor, OpenCode | ❌ | ❌ |
| Official SDKs | C# and Rust ship task support; TypeScript and Python don't yet | None yet |
On the server side it's just as early: OpenAI announced ChatGPT support on September 29, and no popular third-party MCP server emits events yet. If your product has events users care about, this is an early window.
Our approach: @upstash/mcp-toolkit
@upstash/mcp-toolkit is part of Upstash AgentKit. It sits on top of the official MCP TypeScript SDK and adds the two things it doesn't have yet: long-running tools (/tasks) and MCP Events (/events).
npm install @upstash/mcp-toolkit @modelcontextprotocol/server mcp-handler zodThe Redis, QStash and Workflow backends come from @upstash/mcp-toolkit/upstash.
Tasks with @upstash/mcp-toolkit/tasks
/tasks gives you the same idea as Tasks, as ordinary MCP tools, so it works in every client today.
A task tool answers right away with a task ID, and two shared tools, task_status and task_cancel, check on it and stop it. The record lives in Redis and the work runs on QStash or Upstash Workflow, so it outlives the request and the client's timeout.
First, who is calling. verifyToken checks the bearer token and puts your user ID in AuthInfo, and principal reads it back for the toolkit:
// lib/auth.ts
import type { AuthInfo } from "@modelcontextprotocol/server";
// 1. Runs on every MCP request (wired up with withMcpAuth below).
// Returning undefined answers 401.
export async function verifyToken(
req: Request,
bearerToken?: string,
): Promise<AuthInfo | undefined> {
if (!bearerToken) return undefined;
const { sub, client_id } = await verifyJwt(bearerToken); // Clerk, WorkOS, Auth0, your own
return { token: bearerToken, clientId: client_id, scopes: [], extra: { userId: sub } };
}
// 2. The toolkit calls this with the AuthInfo above. Return the user ID, or throw.
export function principal({ auth }: { auth?: AuthInfo }): string {
const userId = auth?.extra?.userId;
if (typeof userId !== "string") throw new Error("Not authenticated");
return userId;
}Then the task layer and a task:
// lib/tasks.ts
import { createTaskLayer } from "@upstash/mcp-toolkit/tasks";
import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-toolkit/upstash";
import * as z from "zod";
import { principal } from "./auth";
export const tasks = createTaskLayer({
store: new RedisTaskStore(),
dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }),
principal,
});
// Module scope, so the execute endpoint knows the handler on every instance.
tasks.define(
"generate_report",
{ description: "Generates a report on a topic.", inputSchema: z.object({ topic: z.string() }) },
async ({ topic }, task) => {
await task.update("Researching sources");
return { content: [{ type: "text", text: await writeReport(topic) }] };
},
);Then the MCP route, where withMcpAuth runs verifyToken on every request:
// app/api/mcp/route.ts
import { createMcpHandler, withMcpAuth } from "mcp-handler";
import { verifyToken } from "@/lib/auth";
import { tasks } from "@/lib/tasks";
const handler = createMcpHandler((server) => {
tasks.register(server); // generate_report, task_status and task_cancel
});
const authHandler = withMcpAuth(handler, verifyToken, { required: true });
export { authHandler as GET, authHandler as POST };And the route QStash delivers the work to:
// app/api/execute/route.ts
import { tasks } from "@/lib/tasks";
export const POST = tasks.createExecuteHandler();From the model's side, it looks like this:
- It calls
generate_reportand gets back ataskIdplus a sentence telling it to calltask_statusin about two seconds. task_statusreturns progress while the task is working: "Researching sources".- Once the task completes,
task_statusreturns a one-line status followed by the report itself.
principal scopes every task to the user who started it: another user's task ID reads as unknown. Return your own user ID, not auth.clientId, which every ChatGPT user shares.
Owning a task isn't the same as being allowed to touch what it works on. If a task takes a workspaceId, the model can pass anyone's. Give tasks.define an authorize to check the arguments before the task is queued, and use task.principal inside the handler.
QStashDispatcher runs the whole handler in one function invocation. For longer work, switch to WorkflowDispatcher: each task.run(...) step gets its own invocation, and finished steps aren't repeated on a retry.
What's next: the native path
Polling through tools costs the model a few turns; the native extension doesn't, which makes it the right long-term shape. The task record and the execution sit behind two small interfaces, a TaskStore and a TaskDispatcher, and the task object already has the extension's shape, so once clients declare io.modelcontextprotocol/tasks a native adapter can serve the same records over tasks/get and keep the tools for everyone else.
Events with @upstash/mcp-toolkit/events
/events is the server side of MCP Events, following what ChatGPT implements: webhook delivery. You define an event with a typed payload, register it on your server, and emit it when something happens. The toolkit handles the events/* methods, callback verification and signing.
Why Redis and QStash? On serverless, nothing survives between requests. Redis keeps the subscriptions where every instance can read them, fast and over HTTP. QStash delivers each event after your function has returned, retries it with backoff, and keeps the ones that keep failing in a dead-letter queue.
QStash calls a small route on your server, which signs each attempt with the subscriber's secret and sends it to the host.
// lib/events.ts
import { createEventLayer } from "@upstash/mcp-toolkit/events";
import {
QStashDelivery,
RedisSubscriptionStore,
} from "@upstash/mcp-toolkit/upstash";
import * as z from "zod";
import { principal } from "./auth";
export const events = createEventLayer({
store: new RedisSubscriptionStore(),
delivery: new QStashDelivery({ url: `${process.env.APP_URL}/api/events` }),
principal, // the same function as for tasks
});
export const commentCreated = events.define("comment.created", {
description: "A new review comment was added to a document.",
input: z.object({ documentId: z.string() }), // what a subscriber filters on (also payload fields)
payload: z.object({ documentId: z.string(), text: z.string() }), // what each delivery carries
// Required. Runs when a host subscribes, and again before every delivery.
authorize: (args, { principal }) => canRead(principal, args.documentId),
});Register the events in the same MCP route, next to the tasks, so subscriptions go through the same withMcpAuth:
// app/api/mcp/route.ts
const handler = createMcpHandler((server) => {
tasks.register(server);
events.register(server); // events/list, events/subscribe, events/unsubscribe
});And add the route QStash delivers each event to:
// app/api/events/route.ts
import { events } from "@/lib/events";
export const POST = events.createDeliveryHandler();Then emit wherever a comment is created:
await commentCreated.emit({ documentId: "doc_123", text: "Can we add rollout dates?" });emit reaches every subscription that matches the payload, here everyone watching doc_123. authorize runs again before each delivery, so someone who lost access stops getting comments. Subscription limits and callback URL checks are in the docs.
Testing it with ChatGPT
We deployed the demo and connected it to ChatGPT as two separate apps: Report Desk for tasks and Deploy Watch for events. Then we logged every JSON-RPC request.
Tasks. We asked for a report on Istanbul ferry routes. ChatGPT made two plain tool calls:
tools/call generate_report → { taskId, status: "working" }
(QStash runs the job for 10 seconds)
tools/call task_status → { status: "completed", result }
ChatGPT labels those calls with the tool titles, so they can look like a native feature. They aren't: the capabilities it sends on every request don't include io.modelcontextprotocol/tasks, so tools are the only way tasks work in ChatGPT today.
Events. We asked ChatGPT to "watch deploy.finished for production" and tell us when a deploy fails. It made these calls:
events/list
tools/call list_recent_deploys (context before subscribing)
events/subscribe deploy.finished { environment: "production" }
→ signed challenge to connectors.api.openai.com, echoed back
ChatGPT turned "for production" into a subscription argument on its own. Then we reported three deploys:
| Deploy | Our server | ChatGPT |
|---|---|---|
| Staging, failed | Matched no subscription, nothing sent | Nothing |
| Production, succeeded | Delivered via QStash, 200 | Ran, stayed quiet as the prompt asked |
| Production, failed | Delivered via QStash, 200 | Posted the failure and emailed us |
So filtering happens in two places: subscription arguments on your server (the staging deploy never left it), and the monitor's prompt on ChatGPT's side (the successful deploy didn't notify us). The notification came about three minutes after delivery, fine for deploy alerts but not for anything time critical.
Each ChatGPT monitor gets its own callback URL, so your server never learns who the ChatGPT user is. It controls who receives what through principal and authorize.
Try it
- Docs: MCP Tasks and MCP Events, each with a quickstart and every option
- Package:
packages/mcp-toolkit - Demo:
examples/mcp-toolkit-demo, a Next.js app with the two servers we tested with ChatGPT: Report Desk (tasks) and Deploy Watch (events). - Specs: the Tasks extension and OpenAI's MCP Events guide.
https://upstash.com/start-redis - no signup required.Upstash runs Redis as a serverless database - create one in seconds and pay only per request. Explore Upstash Redis →