@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.
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
Emit
Callemit 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
- The host calls
events/subscribewith the event name, arguments, its callback URL and awhsec_signing secret. - For a new callback or secret, your server POSTs a signed verification challenge and requires the
host to echo it back, then stores the subscription and answers with its
idand arefreshBeforetime. - When you
emit, every matching subscription thatauthorizestill allows gets a POST with the event envelope and Standard Webhooks headers:
Matching and authorize
Everyinput 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’sdocumentId, and revoked access stops the events. A refusal drops that delivery; a throw (your database is down, say) makes QStash retry it.
auth or request.
authorize: () => true lets every authenticated subscriber hear every matching event.
Deduplicating an emit
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
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
What the layer checks for you
- The callback URL. It must be
httpson a public host name. Refused: every IP literal,localhost, single-label,.localand.internalnames, 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-32015error, 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 underMCP_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_KEYvariables (or areceiveryou pass). Without them it throws on the first request instead of sending unverified deliveries. A bad signature or body gets489withUpstash-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. SetmaxSubscriptionsto change it, orInfinityto turn it off.
Host responses
Host responses
What is stored in Redis
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
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
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):verifyWebhook (Standard Webhooks) for writing a receiver.events/subscribe refuses them) and
event replay (cursor is always null).
Example
The MCP toolkit demo includes Deploy Watch, an events-only server with adeploy.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.