Skip to main content

Most agents only act when someone messages them. MCP Events lets an agent wait for something instead: the host subscribes to an event on your MCP server, and when it happens your server POSTs a signed webhook that wakes the agent up.

@upstash/mcp-toolkit/events implements the server side. You define events with typed payloads and call emit when something happens. The toolkit handles subscriptions, callback verification, signing and retries: subscriptions live in Upstash Redis, and every delivery goes through QStash, which retries failed webhooks with backoff.

Note

MCP Events is a draft. As of October 2026, ChatGPT is the only widely used host that subscribes to it (webhook delivery, in Work chats), following OpenAI’s MCP Events guide. Codex supports events only for OpenAI’s own connectors. Claude Code, Cursor and OpenCode don’t subscribe yet.

Quickstart#

Four files in a Next.js app.

1. Identify the caller#

Every subscription belongs to the user who subscribed. verifyToken turns the request’s bearer token into the SDK’s AuthInfo, with your user id in extra, and principal reads that id back for the toolkit. If you also use MCP Tasks, both layers share this file:

lib/auth.ts

2. Define the event layer and your events#

lib/events.ts

authorize is required. See Matching and authorize for when it runs.

3. Serve the MCP endpoint#

app/api/mcp/route.ts

withMcpAuth hands the AuthInfo from verifyToken to the MCP server, and the toolkit passes it to principal on every subscribe. With MCP Tasks too, call tasks.register(server) in the same callback.

4. Add the delivery endpoint#

app/api/events/route.ts

QStash delivers each event here. The handler verifies the QStash signature, signs the envelope with the subscriber’s secret and POSTs it to the host.

Emit#

Call emit wherever the change happens: a route, a webhook from your own app, a background job.

emit is typed by the payload schema and validates against it. Every subscription that matches the payload, and that authorize still allows for documentId: "doc_123", gets a signed webhook. It returns { eventId }.

Environment variables#

.env

What happens on the wire#

  1. The host calls events/subscribe with the event name, arguments, its callback URL and a whsec_ signing secret.
  2. For a new callback or secret, your server POSTs a signed verification challenge and requires it echoed back, then stores the subscription and answers with its id and a refreshBefore time.
  3. When you emit, every matching subscription that authorize still allows gets a POST with the event envelope and Standard Webhooks headers:

QStash retries failed deliveries with backoff. The event id stays the same across retries, so the host can drop duplicates, and the signature is computed fresh on every attempt.

Matching and authorize#

Every input field must also be a payload field: the payload is the one place an event’s values come from, and the same values are used to route it and to authorize it. The payload’s values for the input fields are parsed with the input schema, so its transforms (.toLowerCase(), say) apply on both sides and authorize gets the types it declares; a payload whose values don’t fit input makes emit throw. A subscription matches when each argument it gave equals the payload’s value. Emitting { documentId: "doc_123", text } reaches subscribers of { documentId: "doc_123" } and of {}.

authorize(args, caller) runs twice:

  • On every subscribe and refresh, with the subscription’s arguments and { principal, phase: "subscribe", auth, request }, before the callback is challenged or anything is stored. A refusal is an error to the host.
  • Before every delivery, with the event’s values for the input fields and { principal, phase: "deliver" }. So a subscriber who filtered on nothing is still checked against each event’s documentId, and revoked access stops the events. A refusal drops that delivery; a throw (your database is down, say) makes QStash retry it.

The token is never stored, so the delivery-time check gets no auth or request. authorize: () => true lets every authenticated subscriber hear every matching event.

Deduplicating an emit

Pass { eventId } as the second argument to emit. It is sent as webhook-id, so the host drops a second emit with the same id as a duplicate:

Users and subscriptions

The host routes to its user. Every subscription carries a callback URL and signing secret that the host generated for it. ChatGPT, for example, sends a unique https://connectors.api.openai.com/webhook/mcp-events/<id> per monitor. Posting to that URL reaches the right user’s agent, so your server never needs to know who the host user is. Two users subscribing to the same event produce two subscriptions and two deliveries.

Your server knows who subscribed. events/subscribe arrives with the same auth as any other MCP request, and principal turns it into the subscriber’s id. events/subscribe and events/unsubscribe resolve principal before anything else, so an unidentified caller learns nothing about your events and is refused with reason not_authenticated. A subscription’s id is a hash of subscriber, callback URL, event and arguments, so a user can only refresh or remove their own. A server with no users of its own passes principal: () => "local".

Every recipient of an emit gets the same payload, so don’t put data in it that only some matching subscribers may see.

Security#

What the layer checks for you
  • The callback URL. It must be https on a public host name. Refused: every IP literal, localhost, single-label, .local and .internal names, and credentials in the URL. Redirects are never followed. Before a subscription is stored, the server POSTs a signed challenge and requires the host to echo it back. Every failure returns the same -32015 error, so a subscriber can’t use it to probe your network (the details go to your logs). DNS is not resolved, so use egress filtering in production.
  • The signing secret. It must be whsec_ followed by 24 to 64 base64 bytes, and it is stored encrypted with AES-256-GCM under MCP_EVENTS_SECRET_KEY, which must be base64 of at least 32 random bytes. A refresh with the same secret skips the challenge. If you rotate the key, stored subscriptions stop receiving events until the host refreshes them.
  • The delivery endpoint. It verifies QStash’s signature with the QSTASH_*_SIGNING_KEY variables (or a receiver you pass). Without them it throws on the first request instead of sending unverified deliveries. A bad signature or body gets 489 with Upstash-NonRetryable-Error: true.
  • Lifetime. A subscription lasts 7 days by default and 30 days at most.
  • How many. A subscriber holds at most 8 live subscriptions across all events. Past that, a new subscribe is refused with reason subscription_limit, before the callback is challenged; refreshing an existing one still works. Each subscription is a webhook per matching emit, so this bounds what one user can make your server send. Set maxSubscriptions to change it, or Infinity to turn it off.
Warning

allowInsecureCallbacks: true turns the callback URL checks off. Use it only for local development, never in production.

Host responses
Callback answersWhat happens
2xxDelivered.
410 GoneThe subscription is deleted.
413, or a redirectThe event is dropped, not retried.
Anything else, or no answerQStash retries with backoff.
What is stored in Redis

mcp-events:sub:<id> is the subscription (event, args, url, encryptedSecret, subscriber, createdAt, expiresAt), expiring with it. mcp-events:idx:<event> and mcp-events:by:<subscriber> are sorted sets of the event’s and the subscriber’s subscription ids, scored by expiry. The second one enforces the per-subscriber limit.

An event message carries the full payload, which stays in QStash (and in its DLQ, if every retry fails) until it is delivered. The toolkit never stores the caller’s token, the request, the plaintext webhook secret or MCP_EVENTS_SECRET_KEY. Anyone with write access to your Redis can change a callback URL, so treat the Redis credentials like any other production secret.

Options#

All options

createEventLayer: store, delivery and principal are required, and so is secretKey unless MCP_EVENTS_SECRET_KEY is set. Optional: maxSubscriptions (8 per subscriber), allowInsecureCallbacks.

events.define(name, config): description, payload and authorize are required. Optional: title, input.

RedisSubscriptionStore: redis (defaults to one from env), prefix (mcp-events:), enableTelemetry.

QStashDelivery: url is required. Optional: qstash, receiver, enableTelemetry. Deliveries are retried 3 times with QStash’s backoff.

Custom backends and receivers

@upstash/mcp-toolkit/events doesn’t import anything from Upstash. A backend implements one of these interfaces (types exported from the same entry point):

The entry point also exports verifyWebhook (Standard Webhooks) for writing a receiver.

Not implemented: the draft’s poll and stream delivery modes (events/subscribe refuses them) and event replay (cursor is always null).

Example#

The MCP toolkit demo includes Deploy Watch, an events-only server with a deploy.finished event filtered by environment, plus a local receiver that verifies each signed delivery. We tested it end to end with ChatGPT monitors. It needs MCP_EVENTS_SECRET_KEY set, and its deploy-report route is unauthenticated so the demo is easy to drive; a real server must authenticate the code that emits.