New:Upstash has a remote MCP server
·12 min read

MCP Tasks and Events, Explained: Long-Running Tools and Agents That Wake Up

Cahid Arda OzCahid Arda OzSoftware Engineer @Upstash
https://upstash.com/blog/mcp-tasks-and-events

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.

Sequence diagram: the client calls a tool, gets a task handle immediately, polls tasks/get, and finally receives the completed result

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, failed or cancelled, 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.

Sequence diagram: the agent host subscribes to an event, the MCP server verifies the callback and stores the subscription, then posts a signed event when the app reports a change

What a server has to implement:

  • events/list, events/subscribe and events/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.

TasksEvents
Who starts itThe client calls a toolSomething happens in the world
How many resultsOne call, one resultZero to many deliveries
LifetimeEnds in a final stateOpen-ended, refreshed until cancelled
DirectionThe client pulls (tasks/get)The server pushes (webhook)
Typical useReports, crawls, migrations, buildsNew 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:

ClientTasksEvents
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 SDKsC# and Rust ship task support; TypeScript and Python don't yetNone 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 zod

The 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.

Architecture: the model starts a task through the MCP server, which records it in Upstash Redis and dispatches it to QStash or Workflow; the execute endpoint runs the handler and writes progress and the result back to Redis, which task_status returns

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:

  1. It calls generate_report and gets back a taskId plus a sentence telling it to call task_status in about two seconds.
  2. task_status returns progress while the task is working: "Researching sources".
  3. Once the task completes, task_status returns 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.

Architecture: ChatGPT subscribes through the MCP server, which stores the subscription in Upstash Redis; on emit the server finds matching subscriptions in Redis and publishes one QStash message each; QStash calls the server's /api/events route with retries, which signs and POSTs the webhook to ChatGPT

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:

DeployOur serverChatGPT
Staging, failedMatched no subscription, nothing sentNothing
Production, succeededDelivered via QStash, 200Ran, stayed quiet as the prompt asked
Production, failedDelivered via QStash, 200Posted 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

If you build something with it, let us know on X or GitHub.

Looking for a managed Redis database?Building with an AI agent? It can create a free database instantly with a POST to 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 →