ai-ml

MCP Events Tutorial: Build a Webhook Server (2026)

September 30, 2026

MCP Events Tutorial: Build a Webhook Server (2026)

TL;DR: MCP Events is a draft Model Context Protocol extension that lets an MCP server notify a client when something happens upstream, so an agent can react. In webhook mode, the server POSTs each event to a callback URL instead of waiting to be polled.1

At DevDay on September 29, 2026, OpenAI announced support for it in ChatGPT plugin automations and listed it as available to all plans. ChatGPT's integration uses webhook delivery only.234

None of the seven MCP SDK and framework packages I installed on September 30 contains any of the draft's events/ method names. So I wrote the webhook side by hand in plain Node.js and ran 24 checks against it.5

Two results matter most. A receiver that de-duplicated on webhook-id alone silently dropped an event when two subscriptions shared one callback URL. And a body that was parsed and re-serialized before verification failed the signature check.

The server and receiver together come to 249 lines of Node.js with one npm dependency, standardwebhooks.

MCP Events: the short answer

MCP Events is a draft extension to the Model Context Protocol. A server lists its event types with events/list. In webhook mode, a client calls events/subscribe with an HTTPS callback URL and a signing secret it generated itself.1

The server confirms that the callback endpoint wants the deliveries, then POSTs each event there, signed per the Standard Webhooks scheme. The client refreshes the subscription before refreshBefore, and can end it early with events/unsubscribe.1

Sequence diagram of MCP Events webhook mode: the client calls events/subscribe with a callback URL and a whsec_ secret, the server sends a signed verification challenge to the receiver unless that principal and URL are already verified, the receiver echoes the challenge, the server returns a subscription id and refreshBefore, then signed event POSTs follow and the client refreshes before expiry

What you'll learn

  • What MCP Events is, who is writing it, and what ChatGPT supports today
  • How events/list, events/subscribe and events/unsubscribe fit together
  • How to validate the whsec_ secret and the callback URL, with the address checked at connection time
  • How the verification challenge and Standard Webhooks signing work, in runnable Node code
  • Three receiver pitfalls I tested: shared-URL de-duplication, re-serialized bodies, and stale or replayed deliveries
  • Which MCP SDK packages ship MCP Events today, measured by searching their published code
  • A production checklist and the full test output

What is MCP Events?

The design sketch describes MCP Events as a way to let "an MCP client subscribe to things happening in an upstream system — a Slack message, a GitHub push, a PagerDuty incident — and have the agent react when they occur, without the user being present."1

The work belongs to the MCP Triggers and Events Working Group. Its charter's changelog dates the initial charter to March 24, 2026, and its leads are Clare Liguori of Amazon Web Services and Peter Alexander of Anthropic.6

The technical detail lives in that design sketch, written by Peter Alexander, dated February 19, 2026, and marked "Draft proposal". OpenAI's page links to it as its protocol reference.31

The incubation repository that holds the sketch is explicit about its status: its contents "are exploratory and do not represent official MCP specifications or recommendations."7

The sketch defines three delivery modes and makes none of them mandatory. Poll mode uses events/poll, push mode uses a long-lived events/stream request, and webhook mode uses events/subscribe and events/unsubscribe.1

What does ChatGPT support?

OpenAI's DevDay 2026 recap lists "MCP events for plugin automations": "We're adding support for the proposed MCP Events specification, so plugins can start automations when something happens in a connected app." It is marked "Available to all plans."2

OpenAI's developer page adds the technical requirements: protocol version 2026-07-28, which the page calls MCP 2.0, persistent subscription storage, and outbound HTTPS access to callback URLs.3

The integration supports webhook delivery and callback verification. In OpenAI's words: "Polling, streaming, and the draft's gap and terminated control notifications are not supported by this integration."3 That is why this tutorial builds webhook mode and nothing else.

In that flow, ChatGPT is the client. When a user asks to monitor an event, "ChatGPT calls events/subscribe with the event name, filter arguments, and webhook destination", and ChatGPT answers the verification challenge.3

I did not connect this server to ChatGPT. Everything below was tested against my own receiver on one machine.

How I tested it

The test rig is three files: server.mjs (the three events methods and the delivery code), receiver.mjs (a webhook endpoint in the client's role) and demo.mjs (24 numbered checks). They ran on Node.js v22.23.2 with standardwebhooks 1.1.1 from npm, on September 30, 2026.8

The JSON-RPC layer is a bare node:http handler, not an SDK transport, because none of the packages I checked has these methods (see the SDK section below). OpenAI's page says to serve them "on the same authenticated MCP endpoint as your tools", so in a real server they sit next to your tools.3

The code marks its shortcuts, and the biggest is DEV_ALLOW_LOOPBACK=1, which lets the demo deliver to http://127.0.0.1. The flag exempts only that exact host. Every other URL still goes through the HTTPS and non-public-address checks, which checks 4 to 6 exercise.

How does events/subscribe work?

A subscribe request names the event, passes arguments that must match the event's inputSchema, and supplies delivery.mode, delivery.url and delivery.secret. Optional fields are cursor, maxAgeMs and ttlMs.1

Only an authenticated caller may use it: servers "MUST reject calls without an authorized principal with -32012 Forbidden."1 The server also MUST check that the principal may subscribe to that event with those arguments. My demo leaves that check as a marked placeholder.1

A webhook subscription is keyed by four values: the principal, the callback URL, the event name and the arguments. The server derives a deterministic id from that key, and a repeat call with the same key refreshes that subscription instead of creating a new one.31

Both documents say to compare arguments as canonical JSON. OpenAI's page explains why: "Compare arguments using canonical JSON so object key order does not create duplicate subscriptions."31

My server sorts keys recursively before hashing. In a separate test, {"document_id":"doc_789","extra":"x"} and {"extra":"x","document_id":"doc_789"} produced the same subscription id.

The response carries id, refreshBefore, cursor and truncated.31 For event types without replay, OpenAI's page says to return cursor: null, and to return refreshBefore: null only when granting a client's ttlMs: null request for a subscription with no expiry.3

Validating the secret and the callback URL

The client generates the secret; the server never does. It must be whsec_ followed by base64 that decodes to 24 to 64 bytes, and servers MUST reject anything else with InvalidParams (-32602).1

Callback URLs must use https://, and a non-HTTPS URL is also rejected with -32602.1 Servers should also reject callback addresses that are not globally routable, and the sketch says this check MUST run at delivery time, not only at subscribe time, to stop DNS rebinding.1

Where the check runs matters. The sketch and OpenAI's page both say to connect to the address you validated while keeping the original hostname for TLS, so the address cannot change between check and connect. Neither allows following redirects.31

My post() does this by giving node:https a custom lookup function, so the socket connects only to an address that function approved. URLs with an IP literal never call lookup, so post() checks those separately. Neither http.request nor https.request follows redirects.

post() also passes its own Agent objects. Without them, a separate test on Node v22.22.2 with NODE_USE_ENV_PROXY=1, HTTPS_PROXY set and NO_PROXY empty sent a localhost callback to the proxy as CONNECT localhost:9, so the private-address check never saw that hostname. With the explicit agents, the same test was rejected with -32602.

I checked the Node behavior this relies on in a separate script on Node v22.23.2, against a local TLS server with a self-signed certificate for events.test. With a test lookup that returned 127.0.0.1, the request reached that address, and the server saw Host: events.test:8443 and SNI events.test.

The same lookup used for other.test failed with ERR_TLS_CERT_ALTNAME_INVALID, so the certificate was still checked against the URL's hostname. An IP-literal URL never called lookup, and a 302 came back as a plain 302 from both http.request and https.request.

isPrivate() uses Node's net.BlockList with the sketch's illustrative ranges, such as 127.0.0.0/8, 10.0.0.0/8 and fc00::/7, plus 0.0.0.0/8 and ::. The sketch points to the full IANA special-purpose registries, so use a maintained list in production.1 For example, my list misses 100.64.0.0/10, which IANA's IPv4 registry names "Shared Address Space" and marks as not globally reachable.9

IPv4-mapped IPv6 literals need care. Node's URL parser turns [::ffff:10.0.0.5] into [::ffff:a00:5], so a check that only unwraps the dotted ::ffff:10.0.0.5 form misses it. net.BlockList matches both forms against its IPv4 rules, and check 5 includes [::ffff:10.0.0.5].

Rejecting a non-public address with -32602 is my choice. The sketch's error table assigns -32602 to malformed and non-HTTPS callback URLs, and names no code for this case.1 The error message leaves out the resolved address, so it does not tell callers which internal IP a hostname maps to.

The verification challenge

The sketch says a server MUST NOT deliver to a callback URL until the endpoint's intent to receive deliveries is confirmed. It allows four ways: a challenge handshake, a server-configured allowlist, prior out-of-band verification, or a well-known document published by the receiver's origin.1

OpenAI's page describes the handshake. The server POSTs a signed {"type":"verification","challenge":"<nonce>"}, the endpoint echoes {"challenge":"<nonce>"} in a 2xx response, and the server compares the challenge in constant time before activating delivery.31

Verification is cached per (principal, url): once a principal's endpoint has passed, its other subscriptions to that URL need no new challenge, and one principal's verification never covers another's.1 OpenAI's page says to keep that cache "for a bounded period"; mine keeps each success for 24 hours.3

Checks 10 and 11 show the cache at work. A second Alice subscription to the same URL sent 0 challenge POSTs, while Bob's first subscription there sent 1.

The cache only helps after a success: in my server, an endpoint that never echoes gets a new challenge on every subscribe attempt. For that case, the sketch says challenge POSTs SHOULD be rate-limited per destination host, which my demo does not do.1

Control envelopes such as the challenge carry a webhook-id of the form msg_<type>_<random>, so receivers can de-duplicate retries.1 A reachable endpoint that does not echo the challenge yields -32015 CallbackEndpointError with data.reason set to challenge_failed; an unreachable one gets the same code with connection_refused, timeout or tls_error.1

My server treats any non-2xx answer to the challenge as challenge_failed, and maps network errors it cannot classify to connection_refused.

Step 1: the MCP server's events methods

This is the complete server.mjs that produced the output below. It offers one event, comment.created, whose name, description and document_id filter come from OpenAI's example.3

// server.mjs — MCP Events webhook mode, written by hand: the SDK releases I checked on 2026-09-30 ship no events/* methods
import http from "node:http";
import https from "node:https";
import crypto from "node:crypto";
import dns from "node:dns";
import net from "node:net";
import { Webhook } from "standardwebhooks";

const DEV_ALLOW_LOOPBACK = process.env.DEV_ALLOW_LOOPBACK === "1"; // local demo only: exempts http://127.0.0.1
const MAX_BODY = 262_144;                                            // 256 KiB, per delivery
const MAX_REQUEST = 1_048_576;                                       // cap for incoming JSON-RPC bodies
const TTL_DEFAULT = 3_600_000, TTL_MIN = 60_000, TTL_MAX = 86_400_000;

const EVENTS = [{
  name: "comment.created",
  description: "A new review comment was added to the specified document.",
  delivery: ["webhook"],
  inputSchema: { type: "object", properties: { document_id: { type: "string" } }, required: ["document_id"] },
  payloadSchema: { type: "object", properties: { document_id: { type: "string" }, comment_id: { type: "string" }, text: { type: "string" } } },
}];

const subs = new Map();      // id -> subscription (use a real database in production)
const verified = new Map();  // "principal url" -> time of its last successful challenge
const VERIFY_TTL = 86_400_000;                                              // re-challenge after 24 hours
const TOKENS = new Map([["token-alice", "alice"], ["token-bob", "bob"]]);   // stand-in for your OAuth layer

class RpcError extends Error { constructor(code, message, data) { super(message); this.code = code; this.data = data; } }
class Blocked extends Error {}   // callback resolves to a non-public address
class TooLarge extends Error {}  // body over MAX_BODY or MAX_REQUEST

// Canonical JSON: recursively sorted keys, so {"a":1,"b":2} and {"b":2,"a":1} key the same subscription
const canon = (v) => Array.isArray(v) ? `[${v.map(canon).join(",")}]`
  : v && typeof v === "object" ? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canon(v[k])}`).join(",")}}`
  : JSON.stringify(v);
const subId = (principal, url, name, args) =>
  "sub_" + crypto.createHash("sha256").update(canon([principal, url, name, args ?? {}])).digest("hex").slice(0, 24);

function checkSecret(secret) {
  if (typeof secret !== "string" || !secret.startsWith("whsec_")) throw new RpcError(-32602, "delivery.secret must start with whsec_");
  const b64 = secret.slice(6);
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64) || b64.length % 4 !== 0) throw new RpcError(-32602, "delivery.secret must be standard base64");
  const n = Buffer.from(b64, "base64").length;
  if (n < 24 || n > 64) throw new RpcError(-32602, `delivery.secret decodes to ${n} bytes; must be 24-64`);
}

function checkUrl(url) {
  let u; try { u = new URL(url); } catch { throw new RpcError(-32602, "delivery.url is malformed"); }
  if (u.protocol !== "https:" && !(DEV_ALLOW_LOOPBACK && u.protocol === "http:" && u.hostname === "127.0.0.1")) throw new RpcError(-32602, "callback URL must use https://");
}

// The design sketch's illustrative non-public ranges, plus 0.0.0.0/8 and ::. Production code needs the full IANA registries.
// net.BlockList also matches IPv4-mapped IPv6 such as ::ffff:a00:5, which the URL parser produces from [::ffff:10.0.0.5].
const blocked = new net.BlockList();
for (const [addr, bits] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.168.0.0", 16]])
  blocked.addSubnet(addr, bits, "ipv4");
for (const [addr, bits] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10]]) blocked.addSubnet(addr, bits, "ipv6");
const isPrivate = (ip) => blocked.check(ip, net.isIPv6(ip) ? "ipv6" : "ipv4");

// DNS-rebinding guard: validate inside the socket's own lookup, so the connection goes to the address that was
// checked, while the Host header and TLS (SNI, certificate) still use the URL's hostname. Node may ask for all addresses.
function safeLookup(hostname, opts, cb) {
  dns.lookup(hostname, { ...opts, all: true }, (err, addrs) => {
    if (err) return cb(err);
    const bad = addrs.find(a => isPrivate(a.address));
    if (bad) return cb(new Blocked("callback resolves to a non-public address"));   // the message leaves out the resolved IP
    opts.all ? cb(null, addrs) : cb(null, addrs[0].address, addrs[0].family);
  });
}

// node:http and node:https never follow redirects, so a 3xx comes back as a plain non-2xx status.
// Explicit agents keep NODE_USE_ENV_PROXY from sending the request through a proxy, around safeLookup.
const agents = { "http:": new http.Agent(), "https:": new https.Agent() };
function post(url, headers, body) {
  const u = new URL(url);
  const host = u.hostname.replace(/^\[|\]$/g, "");
  const devLoopback = DEV_ALLOW_LOOPBACK && host === "127.0.0.1";
  if (!devLoopback && net.isIP(host) && isPrivate(host))    // IP-literal URLs never reach lookup, so check them here
    return Promise.reject(new Blocked("callback resolves to a non-public address"));
  return new Promise((resolve, reject) => {
    let gotResponse = false;
    const req = (u.protocol === "https:" ? https : http).request(u, {
      method: "POST", headers, agent: agents[u.protocol], signal: AbortSignal.timeout(10_000),   // 10-second deadline per attempt
      ...(devLoopback ? {} : { lookup: safeLookup }),
    }, res => {
      gotResponse = true;
      let text = ""; res.setEncoding("utf8");
      res.on("data", c => {                          // stop reading the answer after 4,096 characters
        text += c;
        if (text.length > 4096) { res.destroy(); resolve({ status: res.statusCode, text: "" }); }
      });
      res.on("end", () => resolve({ status: res.statusCode, text }));
      res.on("error", reject);
    });
    req.on("upgrade", (res, socket) => { gotResponse = true; socket.destroy(); resolve({ status: res.statusCode, text: "" }); });  // a 101 answer
    req.on("error", reject);
    req.on("close", () => { if (!gotResponse) reject(new Error("connection closed without a response")); });
    req.end(body);
  });
}

// Each call signs with a fresh timestamp, so every retry attempt is re-signed.
async function signedPost(sub, webhookId, bodyObj) {
  const body = JSON.stringify(bodyObj);
  const size = Buffer.byteLength(body);
  if (size > MAX_BODY) throw new TooLarge(`payload ${size} bytes > ${MAX_BODY}`);
  const signedAt = new Date();
  return post(sub.url, {
    "Content-Type": "application/json",
    "Content-Length": size,
    "webhook-id": webhookId,
    "webhook-timestamp": String(Math.floor(signedAt.getTime() / 1000)),
    "webhook-signature": new Webhook(sub.secret).sign(webhookId, signedAt, body),
    "X-MCP-Subscription-Id": sub.id,
  }, body);
}

async function verifyCallback(sub) {
  const challenge = crypto.randomBytes(24).toString("base64url");
  let res;
  try { res = await signedPost(sub, `msg_verification_${crypto.randomBytes(8).toString("hex")}`, { type: "verification", challenge }); }
  catch (e) {
    if (e instanceof Blocked) throw new RpcError(-32602, e.message);   // error code is my choice, see the post
    // Unreachable endpoint: timeout, TLS failure, or (as a catch-all for other network errors) connection_refused
    const reason = e.name === "AbortError" ? "timeout" : /CERT|TLS|SSL|UNABLE_TO_VERIFY|EPROTO/.test(e.code ?? "") ? "tls_error" : "connection_refused";
    throw new RpcError(-32015, "CallbackEndpointError", { reason });
  }
  // Reachable endpoint that does not echo the challenge in a 2xx body: challenge_failed
  let echoed = ""; try { echoed = String(JSON.parse(res.text).challenge ?? ""); } catch {}
  const a = Buffer.from(echoed), b = Buffer.from(challenge);
  const ok = res.status >= 200 && res.status < 300 && a.length === b.length && crypto.timingSafeEqual(a, b);
  if (!ok) throw new RpcError(-32015, "CallbackEndpointError", { reason: "challenge_failed" });
}

const methods = {
  "events/list": async () => ({ events: EVENTS }),

  "events/subscribe": async (p, principal) => {
    if (!EVENTS.some(e => e.name === p.name)) throw new RpcError(-32011, "NotFound", { kind: "event" });
    if (p.delivery?.mode !== "webhook") throw new RpcError(-32014, "Unsupported", { feature: "deliveryMode", value: p.delivery?.mode });
    if (typeof p.arguments?.document_id !== "string") throw new RpcError(-32602, "arguments.document_id is required");
    checkSecret(p.delivery.secret);
    checkUrl(p.delivery.url);
    if (p.ttlMs != null && !Number.isFinite(p.ttlMs)) throw new RpcError(-32602, "ttlMs must be a number or null");
    // Placeholder: the sketch says the server MUST check that `principal` may subscribe with these arguments.
    const id = subId(principal, p.delivery.url, p.name, p.arguments);
    const ttl = p.ttlMs === undefined ? TTL_DEFAULT : Math.min(Math.max(p.ttlMs ?? TTL_MAX, TTL_MIN), TTL_MAX);
    const sub = { id, name: p.name, args: p.arguments, url: p.delivery.url, secret: p.delivery.secret, expiresAt: Date.now() + ttl };
    const vkey = `${principal} ${sub.url}`;                 // verification is cached per (principal, url)
    if (!(Date.now() - (verified.get(vkey) ?? 0) < VERIFY_TTL)) { await verifyCallback(sub); verified.set(vkey, Date.now()); }
    subs.set(id, sub);                                       // same key again = refresh: new TTL, secret replaced
    return { id, refreshBefore: new Date(sub.expiresAt).toISOString(), cursor: null, truncated: false };
  },

  // Idempotent with an empty result, per OpenAI's page (the sketch's error table would return -32011 instead)
  "events/unsubscribe": async (p, principal) => {
    subs.delete(subId(principal, p.delivery?.url, p.name, p.arguments));
    return {};
  },
};

// Called by your app when something happens upstream. The same eventId goes to every matching subscription.
export async function emit(name, data, { eventId = `evt_${crypto.randomUUID()}`, maxAttempts = 4, backoffMs = 250 } = {}) {
  const timestamp = new Date().toISOString(), results = [];
  for (const sub of subs.values()) {
    if (sub.name !== name || sub.args.document_id !== data.document_id || sub.expiresAt < Date.now()) continue;
    const event = { eventId, name, timestamp, data, cursor: null };
    let status = "error", attempts = 0;
    while (attempts < maxAttempts) {
      attempts++;
      try {
        status = (await signedPost(sub, eventId, event)).status;   // webhook-id is the eventId on every attempt
        if ((status >= 200 && status < 300) || status === 410 || status === 413) break;   // done, or non-retryable
      } catch (e) { status = e.message; if (e instanceof TooLarge || e instanceof Blocked) break; }
      if (attempts < maxAttempts) await new Promise(r => setTimeout(r, backoffMs * 2 ** (attempts - 1)));
    }
    results.push({ sub: sub.id, status, attempts });
  }
  return results;
}

async function readBody(req, limit) {             // throws past the limit, which drops the connection
  const chunks = []; let size = 0;
  for await (const c of req) { size += c.length; if (size > limit) throw new TooLarge("request body too large"); chunks.push(c); }
  return Buffer.concat(chunks);
}

async function handle(req, res) {
  const raw = await readBody(req, MAX_REQUEST);
  let msg = null;
  const reply = (o) => { res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ jsonrpc: "2.0", id: msg?.id ?? null, ...o })); };
  try {
    msg = JSON.parse(raw.toString("utf8"));
    if (!msg || typeof msg !== "object" || Array.isArray(msg)) { msg = null; throw new RpcError(-32600, "Invalid Request"); }
    // OpenAI: serve these methods on the same authenticated MCP endpoint as your tools
    const principal = TOKENS.get(/^Bearer +(\S+)$/i.exec(req.headers.authorization ?? "")?.[1]);
    if (!principal) throw new RpcError(-32012, "Forbidden");
    if (!Object.hasOwn(methods, msg.method)) throw new RpcError(-32601, "Method not found");
    reply({ result: await methods[msg.method](msg.params ?? {}, principal) });
  } catch (e) {
    const code = typeof e.code === "number" ? e.code : e instanceof SyntaxError ? -32700 : -32603;
    reply({ error: { code, message: e.message, ...(e.data && { data: e.data }) } });
  }
}

export function startServer(port) {
  return http.createServer((req, res) => handle(req, res).catch(() => res.destroy())).listen(port);   // e.g. client aborted mid-body
}

The emit() function is what your application calls when something happens upstream. It sends one eventId to every matching, unexpired subscription. On retries it keeps that ID, as OpenAI's page asks: "Use a unique event ID and preserve it across retries."3

Sending one eventId to several subscriptions is what happens when, as the sketch prefers, eventId is the upstream's stable ID.1 Pitfall 1 below shows what that means for receivers.

Each attempt is signed again with a fresh timestamp. The sketch requires this so that retries are not rejected by the receiver's 5-minute freshness window.1 Each attempt also has a 10-second deadline, set with AbortSignal.timeout(10_000) as in OpenAI's example.3

How are MCP Events webhooks signed?

Every delivery carries the three Standard Webhooks headers, webhook-id, webhook-timestamp and webhook-signature, plus X-MCP-Subscription-Id. For events, webhook-id is the event's eventId, and X-MCP-Subscription-Id lets a receiver pick the right secret before parsing the body.1

The signature is v1, followed by base64 of HMAC-SHA256 over webhook-id.webhook-timestamp.body, keyed with the base64-decoded secret after the whsec_ prefix.1 The standardwebhooks package's sign() builds exactly that string, and its verify() rejects timestamps more than 5 minutes old or more than 5 minutes in the future.8

The sketch says the receiver "SHOULD reject deliveries where webhook-timestamp is more than 5 minutes old", so the library's default matches.1 During secret rotation, the header may carry several space-delimited signatures, and the receiver accepts the delivery if any one of them verifies.1

Each delivery holds one event. OpenAI's page caps the request body at 256 KiB (262,144 bytes), receivers may reject a larger body with 413, and servers must not retry 410 or 413.31

Step 2: the webhook receiver

This file plays the client's side. When ChatGPT is the client, the callback endpoint is ChatGPT's, not yours.3 You still need one to test your server, and you need a real one if you build your own agent host.

// receiver.mjs — the client side: verify the received body, answer the challenge, dedupe per subscription
import http from "node:http";
import { Webhook } from "standardwebhooks";

async function readBody(req, limit) {             // throws past the limit, which drops the connection
  const chunks = []; let size = 0;
  for await (const c of req) { size += c.length; if (size > limit) throw new Error("body too large"); chunks.push(c); }
  return Buffer.concat(chunks);
}

export function startReceiver(port, secret, { log = [], failNext = 0 } = {}) {
  const wh = new Webhook(secret);  // one shared secret keeps the demo short; see Pitfall 1 for why production needs one per subscription
  const seen = new Set();
  const state = { log, failNext, verifications: 0 };
  async function handle(req, res) {
    if (Number(req.headers["content-length"]) > 262_144) { res.writeHead(413, { connection: "close" }).end(); return; }
    const raw = await readBody(req, 262_144);      // verify the body as received; never parse and re-stringify first
    let body;
    try { body = wh.verify(raw, req.headers); }
    catch (e) { state.log.push({ rejected: e.message }); res.writeHead(400).end(); return; }
    if (!body || typeof body !== "object") { res.writeHead(400).end(); return; }
    if ("type" in body) {                          // a top-level "type" field marks a control envelope
      if (body.type === "verification") {
        state.verifications++;
        res.setHeader("content-type", "application/json");
        res.end(JSON.stringify({ challenge: body.challenge })); return;
      }
      state.log.push({ control: body.type }); res.writeHead(204).end(); return;
    }
    if (state.failNext > 0) { state.failNext--; res.writeHead(503).end(); return; }   // simulate an outage
    const id = req.headers["webhook-id"];
    // One eventId can reach several subscriptions at this URL, so dedupe on (subscription, id), not id alone.
    // X-MCP-Subscription-Id is not signed: this key is only safe when each subscription has its own secret.
    const key = process.env.NAIVE_DEDUPE === "1" ? id : `${req.headers["x-mcp-subscription-id"]}:${id}`;
    if (seen.has(key)) { state.log.push({ duplicate: id }); res.writeHead(200).end(); return; }
    seen.add(key);
    state.log.push({ accepted: id, sub: req.headers["x-mcp-subscription-id"], data: body.data });
    res.writeHead(204).end();                      // production: persist or enqueue before acknowledging
  }
  const server = http.createServer((req, res) => handle(req, res).catch(() => res.destroy())).listen(port);
  return { server, state };
}

Two details carry most of the value: the signature is checked against the body as received, before any JSON parsing, and de-duplication is keyed on the subscription ID plus webhook-id. The next section shows why both matter, and why the second needs a secret per subscription.

Three receiver pitfalls, tested

Pitfall 1: de-duplicating on webhook-id alone drops events

Retries mean the same delivery can arrive more than once, and the sketch says the receiver "SHOULD deduplicate on webhook-id".1 The Standard Webhooks specification gives similar advice.8

When eventId is the upstream's stable ID, though, one upstream event reaches every matching subscription with the same webhook-id. If two of those subscriptions deliver to the same URL, a receiver keyed on webhook-id alone treats the second as a duplicate.

In checks 8 and 11, Alice and Bob each subscribed to doc_123 at the same callback URL and got different subscription IDs. With NAIVE_DEDUPE=1, which keys on webhook-id only, checks 12 and 13 produced this:

12 emit doc_123                      [{"sub":"sub_045c6f33dc958d6167267857","status":204,"attempts":1},{"sub":"sub_3fe4e80b802025bef4790597","status":200,"attempts":1}]
13 receiver kept                     ["sub_045c6f33"]

The receiver kept one delivery and answered the other with 200 as a duplicate. The server saw a 2xx and did not retry, so Bob's subscription lost the event with no error on either side.

Keying on X-MCP-Subscription-Id plus webhook-id fixed it, and the receiver kept both deliveries. Whether two subscriptions ever share a URL depends on the client, but the protocol allows it: the URL is only one of the four parts of the subscription key.1

That fix has a condition. X-MCP-Subscription-Id is not covered by the signature, and the demo gives every subscription the same secret. In a separate test, one signed delivery was accepted (204), answered as a duplicate (200) when replayed unchanged, and accepted again (204) when replayed with a different X-MCP-Subscription-Id.

So give each subscription its own secret and pick it by X-MCP-Subscription-Id, which is what the sketch says the header is for.1 A replay with a changed header then selects the wrong secret and fails verification.

One wrinkle: the challenge arrives before the subscribe response has told the client the new id, so the receiver cannot pick the challenge's secret by that header yet. It has to check the challenge against the secret it just sent, for example by giving each subscription its own callback URL, and record the id when the response arrives.

Pitfall 2: re-serializing the body breaks the signature

The signature covers the exact bytes sent. The sketch is blunt: "Receivers MUST compute the HMAC over the raw body, never over a re-serialized JSON object."1 Frameworks that parse JSON first and hand you an object make it tempting to JSON.stringify() it again.

Check 21 shows why that fails. Python's json.dumps() escapes non-ASCII characters by default, so a Python server using the default sends café as caf\u00e9.10 Node's JSON.stringify() writes café, so re-serializing changes the bytes, and verification fails with "No matching signature found".

I simulated the Python-style body in Node rather than running a Python server. The escaping itself was confirmed with Python 3.10.12: json.dumps({'t':'café'}) printed {"t": "caf\u00e9"}.

Pitfall 3: stale and replayed deliveries

Check 20 signed a valid body with a timestamp 6 minutes old, and the receiver rejected it with "Message timestamp too old". Check 18 replayed an already-accepted webhook-id, and the receiver answered 200 without processing it twice.

Answer a duplicate with 2xx or with 410 Gone. The sketch names 410 for the case where a receiver "recognises the event as stale or already processed", and servers MUST NOT retry it. Other non-2xx responses, except 413, are retried.1

Step 3: run the full test

Save the three files in src/, install one dependency, then run the demo:

npm init -y && npm pkg set type=module
npm install standardwebhooks@1.1.1
DEV_ALLOW_LOOPBACK=1 node src/demo.mjs

This is demo.mjs as it ran:

// demo.mjs — run with: DEV_ALLOW_LOOPBACK=1 node src/demo.mjs
import crypto from "node:crypto";
import { Webhook } from "standardwebhooks";
import { startServer, emit } from "./server.mjs";
import { startReceiver } from "./receiver.mjs";

const secret = "whsec_" + crypto.randomBytes(32).toString("base64");
const srv = startServer(8787);
const rx = startReceiver(9797, secret);
const CALLBACK = "http://127.0.0.1:9797/mcp-events/cb_1";

let n = 0;
const rpc = async (method, params, token = "token-alice") => (await (await fetch("http://127.0.0.1:8787/mcp", {
  method: "POST",
  headers: { "content-type": "application/json", ...(token && { authorization: `Bearer ${token}` }) },
  body: JSON.stringify({ jsonrpc: "2.0", id: ++n, method, params }),
})).json());
const sub = (over = {}) => ({ name: "comment.created", arguments: { document_id: "doc_123" },
  delivery: { mode: "webhook", url: CALLBACK, secret }, cursor: null, ...over });
const at = (url, s = secret) => sub({ delivery: { mode: "webhook", url, secret: s } });
const show = (label, v) => console.log(label.padEnd(36), JSON.stringify(v.error ?? v.result ?? v));

show("1 events/list names", { result: (await rpc("events/list")).result.events.map(e => e.name) });
show("2 no bearer token", await rpc("events/subscribe", sub(), null));
show("3 secret 16 bytes", await rpc("events/subscribe", at(CALLBACK, "whsec_" + crypto.randomBytes(16).toString("base64"))));
show("4 plain-http public URL", await rpc("events/subscribe", at("http://example.com/cb")));
show("5 https URL, IP literals", { result: (await Promise.all(["https://10.0.0.5/cb", "https://[::ffff:10.0.0.5]/cb"]
  .map(u => rpc("events/subscribe", at(u))))).map(r => `${r.error.code} ${r.error.message}`) });
show("6 https URL, localhost via DNS", await rpc("events/subscribe", at("https://localhost:9797/cb")));
show("7 wrong secret at receiver", await rpc("events/subscribe", at(CALLBACK, "whsec_" + crypto.randomBytes(32).toString("base64"))));
const first = await rpc("events/subscribe", sub());
show("8 subscribe ok", first);
const again = await rpc("events/subscribe", sub({ ttlMs: 120000 }));
show("9 same key again (refresh)", { result: { sameId: again.result.id === first.result.id, refreshBefore: again.result.refreshBefore } });
let v = rx.state.verifications;
const other = await rpc("events/subscribe", sub({ arguments: { document_id: "doc_456" } }));
show("10 alice, doc_456, same URL", { result: { newId: other.result.id !== first.result.id, verificationPosts: rx.state.verifications - v } });
v = rx.state.verifications;
const bob = await rpc("events/subscribe", sub(), "token-bob");
show("11 bob, same URL + arguments", { result: { newId: bob.result.id !== first.result.id, verificationPosts: rx.state.verifications - v } });

show("12 emit doc_123", { result: await emit("comment.created", { document_id: "doc_123", comment_id: "c_1", text: "Add rollout dates?" }) });
show("13 receiver kept", { result: rx.state.log.filter(l => l.accepted).map(l => l.sub.slice(0, 12)) });
show("14 emit doc_999 (no match)", { result: await emit("comment.created", { document_id: "doc_999", comment_id: "c_2", text: "x" }) });
rx.state.failNext = 2;
show("15 receiver 503 x2 then ok", { result: await emit("comment.created", { document_id: "doc_123", comment_id: "c_3", text: "retry me" }) });
show("16 oversized event", { result: await emit("comment.created", { document_id: "doc_123", comment_id: "c_4", text: "x".repeat(300_000) }) });

// Receiver-side checks, sent straight at the callback
const post = async (headers, body) => (await fetch(CALLBACK, { method: "POST", headers, body })).status;
const good = JSON.stringify({ eventId: "evt_x", name: "comment.created", timestamp: new Date().toISOString(), data: { document_id: "doc_123", text: "café" }, cursor: null });
const signer = new Webhook(secret);
const hdr = (id, when, body) => ({ "content-type": "application/json", "webhook-id": id, "webhook-timestamp": String(Math.floor(when / 1000)),
  "webhook-signature": signer.sign(id, new Date(when), body), "X-MCP-Subscription-Id": first.result.id });
show("17 valid delivery", { result: await post(hdr("evt_x", Date.now(), good), good) });
show("18 same webhook-id replayed", { result: await post(hdr("evt_x", Date.now(), good), good) });
show("19 body tampered after signing", { result: await post(hdr("evt_y", Date.now(), good), good.replace("café", "cafe")) });
show("20 signed 6 minutes ago", { result: await post(hdr("evt_z", Date.now() - 360_000, good), good) });
// Python's json.dumps() escapes non-ASCII by default; a receiver that parses and re-stringifies changes the bytes
const pyStyle = good.replace(/[\u0080-\uffff]/g, c => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
const reSer = JSON.stringify(JSON.parse(pyStyle));
show("21 re-serialized bytes equal?", { result: { sameBytes: reSer === pyStyle, verifyAfterReserialize: (() => { try { signer.verify(reSer, hdr("evt_p", Date.now(), pyStyle)); return "pass"; } catch (e) { return e.message; } })() } });

const unsub = { name: "comment.created", arguments: { document_id: "doc_123" }, delivery: { mode: "webhook", url: CALLBACK } };
show("22 unsubscribe", await rpc("events/unsubscribe", unsub));
show("23 unsubscribe again", await rpc("events/unsubscribe", unsub));
show("24 emit after alice unsubscribes", { result: (await emit("comment.created", { document_id: "doc_123", comment_id: "c_5", text: "?" })).map(r => r.sub.slice(0, 12)) });
srv.close(); rx.server.close();

And this is its output, rendered as an image, with the command on the first line:

Output of demo.mjs on Node v22.23.2 with standardwebhooks 1.1.1, rendered from the captured stdout: 24 checks covering auth errors, secret and URL validation including an IPv4-mapped IPv6 literal and a localhost URL rejected during DNS lookup, a CallbackEndpointError with challenge_failed, idempotent refresh, a cached verification, filtered delivery, a retry that succeeds on the third attempt, a refused 300,194-byte event, tampered, stale and re-serialized deliveries rejected, and idempotent unsubscribe

What each group of checks shows:

ChecksWhat was testedResult
2Subscribe with no Authorization header-32012 Forbidden
3Secret that decodes to 16 bytes-32602, below the 24-byte minimum
4Plain-HTTP public URL-32602
5, 6HTTPS URLs on the IP literals 10.0.0.5 and [::ffff:10.0.0.5]; HTTPS URL on localhost-32602 for all three; localhost is not an IP literal, so it was rejected inside lookup
7Receiver holds a different secretThe receiver rejects the challenge's signature, so nothing is echoed: -32015 with challenge_failed
8, 9Subscribe, then repeat with ttlMs: 120000Same id; refreshBefore moves to 2 minutes out
10Alice subscribes to doc_456 at the same URLNew id, 0 challenge POSTs
11Bob subscribes with Alice's URL and argumentsNew id, 1 challenge POST
14Event for doc_999No matching subscription, no delivery
15Receiver returns 503 twiceAlice's delivery succeeds on attempt 3
16300,194-byte eventRefused before sending, no retry
19Body changed after signingReceiver 400
22–24Unsubscribe twice, then emit{} both times; only Bob's subscription still receives

The subscription IDs repeat across runs because each is a truncated SHA-256 of the key, not a random value. The sketch gives that as its example of a deterministic id.1

The demo retries with a 250 ms backoff that doubles, so the test finishes in seconds. The sketch says to cap attempts and elapsed time, "for example, 3–5 attempts spread over no more than 10–15 minutes", so stretch the backoff in production.1

Do the MCP SDKs support MCP Events yet?

Not in the packages I measured. On September 30, 2026, I installed seven packages from npm and PyPI and searched every installed file for six literal strings: events/list, events/poll, events/stream, events/subscribe, events/unsubscribe and notifications/events.5

Every package had zero matches for all six, and so did a search of the full installed dependency trees, which include mcp-types 2.2.0 and fastmcp-slim 4.0.10. As a positive control, the same search for tools/list matched files in every package:

PackageSource repositoryVersion (published)events/ stringstools/list files
@modelcontextprotocol/sdk (npm)modelcontextprotocol/typescript-sdk1.31.0 (Sept 28, 2026)022
@modelcontextprotocol/server (npm)modelcontextprotocol/typescript-sdk2.2.0 (Sept 28, 2026)010
@modelcontextprotocol/client (npm)modelcontextprotocol/typescript-sdk2.2.0 (Sept 28, 2026)012
@modelcontextprotocol/core (npm)modelcontextprotocol/typescript-sdk2.2.0 (Sept 28, 2026)06
mcp (PyPI)modelcontextprotocol/python-sdk2.2.0 (Sept 7, 2026)08
fastmcp (PyPI; code ships in fastmcp-slim)PrefectHQ/fastmcp4.0.10 (Sept 25, 2026)011
fastmcp (npm)punkpeye/fastmcp4.22.1 (Sept 30, 2026)013

The working group's charter lists "Reference implementation in Tier-1 SDKs" as an active work item, and its success criteria include "Reference implementations in at least two Tier-1 SDKs."6 Until those ship, the webhook methods are yours to write.

This covers published releases on one date. Unreleased branches and pull requests were not checked.

Production checklist for MCP Events

  • Store subscriptions to match the TTLs you grant. The sketch lets a server that grants short TTLs keep subscriptions in memory, but OpenAI's page asks ChatGPT integrations to "retain subscription state for the lifetime you grant, including across server restarts."31 My Map is demo-only.
  • Check permissions at subscribe time and during delivery. The sketch requires a subscribe-time permission check, and OpenAI asks you to "recheck the user's access during the subscription's lifetime and stop delivery if access is revoked."31
  • Validate callback addresses at connection time. Check the resolved address inside the connection, as post() does, and never follow redirects.31
  • Rate-limit challenge POSTs per destination host. The sketch says verification POSTs SHOULD be rate-limited per destination host; my demo does not do this.1
  • Keep eventId stable across retries, sign every attempt again, and never retry 410 or 413.31
  • Deliver to each subscription independently. My emit() finishes one subscription's delivery, retries included, before starting the next, so one slow endpoint delays the others.
  • Cap body sizes in both directions. My server reads at most 1 MiB of a JSON-RPC request and stops reading a callback's answer after 4,096 characters, and the receiver refuses deliveries over 256 KiB.
  • Honor ttlMs within limits. The sketch says "grants from a few minutes up to about a day cover most deployments."1 My server clamps numeric suggestions to between 60 seconds and 24 hours, grants 24 hours when a client asks for null, and rejects a non-numeric ttlMs with -32602.
  • Plan for secret rotation. A refresh with a new secret replaces the old one, and the sketch says servers SHOULD sign with both old and new secrets for a short grace window.1 My server does not dual-sign: in a separate test, a delivery after rotation got a 400 from a receiver still holding the old secret.
  • Acknowledge only what you have stored. The sketch says the endpoint "SHOULD NOT return 2xx until the event has been durably persisted or forwarded."1 My receiver acknowledges after an in-memory log entry.
  • On the receiver, verify the body before parsing it, use a separate secret per subscription, and de-duplicate per subscription. See Pitfalls 1 and 2.
  • Evolve event schemas additively. The sketch says "A breaking change SHOULD instead be published under a new event name, served alongside the old one for a migration period".1

The working group's charter notes that SEP-1686, the Tasks proposal, "identifies webhook-style task completion notifications as a future consideration; this WG owns that mechanism."6 For long-running tool calls, see the MCP Tasks extension tutorial.

For what the 2026-07-28 spec changed overall, see MCP goes stateless. Another Standard Webhooks integration, with a different signing pitfall, is covered in Claude inference hooks.

Limits of these tests

Everything ran on one machine over HTTP on loopback. The Node behavior that post() relies on for TLS was checked in a separate script against a local server, but post() itself never made a TLS connection. Certificate failures, DNS rebinding attacks and network timeouts were not exercised end to end.

I did not connect to ChatGPT, so how it refreshes subscriptions and answers challenges is described only as OpenAI's page states it. Poll and push delivery were not built, because ChatGPT's integration does not support them.3

The server authenticates callers with a hard-coded token map, does not check per-document permissions, does not rate-limit challenges, and does not dual-sign during secret rotation. The demo receiver uses one secret for every subscription.

The design sketch is a draft and may change before the working group has an accepted SEP.167

Bottom line

Webhook-mode MCP Events is small: three JSON-RPC methods, a signed challenge that is cached per principal and URL once it succeeds, and one signed POST per event. OpenAI lists ChatGPT support as available to all plans, while none of the SDK packages I checked ships the methods yet.25

Write the server side yourself for now. Validate secrets, check callback addresses at connection time, and keep event IDs stable. Then test your receiver for shared-URL de-duplication, per-subscription secrets and verification before parsing, before real events depend on it.

Footnotes

  1. MCP Events design sketch (docs/design-sketch-proposal.md) — modelcontextprotocol/experimental-ext-triggers-events on GitHub, status "Draft proposal", author Peter Alexander, dated 2026-02-19; raw file read on 2026-09-30. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 ↩26 ↩27 ↩28 ↩29 ↩30 ↩31 ↩32 ↩33 ↩34 ↩35 ↩36 ↩37 ↩38 ↩39 ↩40 ↩41 ↩42 ↩43 ↩44 ↩45 ↩46 ↩47 ↩48 ↩49 ↩50 ↩51 ↩52

  2. DevDay 2026 Recap — OpenAI, published September 29, 2026, fetched 2026-09-30 ("MCP events for plugin automations"; "Available to all plans."). ↩ ↩2 ↩3 ↩4

  3. MCP Events — Plugins, OpenAI Developers, fetched 2026-09-30 (requirements; unsupported features; the three methods on the authenticated MCP endpoint; who calls events/subscribe and answers the challenge; subscribe, refresh and unsubscribe rules; connection-time address validation; delivery headers, retries, the 256 KiB limit and 410/413 handling; the comment.created example). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25

  4. OpenAI teases 20+ announcements at DevDay, watch live — 9to5Mac, published September 29, 2026, fetched 2026-09-30 ("It kicks off at 10 am PT/1 pm ET"). ↩ ↩2

  5. Author's measurement, 2026-09-30: seven packages installed from npm and PyPI; versions, publish dates and source repositories from the npm registry (npm view) and PyPI's JSON API; each installed package directory searched with grep -rlF for the six strings and the tools/list control, with compiled __pycache__ copies excluded. The counts are numbers of files containing the string. For fastmcp on PyPI, the importable fastmcp package is installed by its dependency fastmcp-slim 4.0.10 (published the same day), so that directory is the one counted. The full installed dependency trees (the venv's site-packages and both node_modules folders) were also searched for the six strings. ↩ ↩2 ↩3 ↩4

  6. Triggers and Events Charter — Model Context Protocol, fetched 2026-09-30 (changelog entry "2026-03-24 | Initial charter"; leads Clare Liguori, Amazon Web Services, and Peter Alexander, Anthropic; active work items, success criteria and related groups). ↩ ↩2 ↩3 ↩4

  7. modelcontextprotocol/experimental-ext-triggers-events — GitHub, raw README read on 2026-09-30. ↩ ↩2 ↩3

  8. standardwebhooks 1.1.1 on npm (published 2026-08-28; repository standard-webhooks/standard-webhooks), dist/index.js read after install on 2026-09-30: sign() returns v1, plus base64 HMAC-SHA256 over the message ID, timestamp and payload joined by dots; verify() accepts any matching v1 signature in a space-separated list; WEBHOOK_TOLERANCE_IN_SECONDS = 5 * 60. Also the Standard Webhooks specification, read 2026-09-30 (secrets of 24 to 64 bytes with a whsec_ prefix, space-delimited signatures for rotation, webhook-id as an idempotency key). ↩ ↩2 ↩3 ↩4

  9. IANA IPv4 Special-Purpose Address Space registry, fetched 2026-09-30 (100.64.0.0/10, "Shared Address Space", Globally Reachable: False). ↩

  10. json — JSON encoder and decoder, Python documentation (ensure_ascii defaults to true), confirmed locally with Python 3.10.12 on 2026-09-30. ↩

Frequently Asked Questions

MCP Events is a draft Model Context Protocol extension that lets a server notify a client when something happens upstream. Servers list event types with events/list, and webhook mode adds events/subscribe and events/unsubscribe, with deliveries signed per Standard Webhooks.1