---
title: 'MCP Events Tutorial: Build a Webhook Server (2026)'
description: 'MCP Events tutorial: a tested Node.js webhook server with events/subscribe, Standard Webhooks signing and challenge checks. The SDKs I checked lack it.'
url: https://nerdleveltech.com/mcp-events-webhook-server-tutorial
language: en
date: '2026-09-30'
category: ai-ml
tags:
  - mcp events
  - model context protocol
  - mcp webhooks
  - standard webhooks
  - chatgpt mcp events
  - ai agents
  - node.js
markdown_url: https://nerdleveltech.com/mcp-events-webhook-server-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/mcp-events-webhook-server-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# 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.[^3]

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.[^1][^2][^9]

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.[^7]

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.[^3]

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`.[^3]

![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](https://nerdleveltech.com/images/mcp-events-webhook-sequence.png)

## 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."[^3]

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.[^4]

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.[^2][^3]

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."[^5]

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`.[^3]

## 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."[^1]

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.[^2]

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."[^2] 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.[^2]

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.[^6]

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.[^2]

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`.[^3]

Only an authenticated caller may use it: servers "MUST reject calls without an authorized principal with `-32012 Forbidden`."[^3] 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.[^3]

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.[^2][^3]

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."[^2][^3]

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`.[^2][^3] 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.[^2]

### 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`).[^3]

Callback URLs must use `https://`, and a non-HTTPS URL is also rejected with `-32602`.[^3] 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.[^3]

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.[^2][^3]

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.[^3] 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.[^10]

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.[^3] 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.[^3]

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.[^2][^3]

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.[^3] OpenAI's page says to keep that cache "for a bounded period"; mine keeps each success for 24 hours.[^2]

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.[^3]

Control envelopes such as the challenge carry a `webhook-id` of the form `msg_<type>_<random>`, so receivers can de-duplicate retries.[^3] 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`.[^3]

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.[^2]

```javascript
// 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 host 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."[^2]

Sending one `eventId` to several subscriptions is what happens when, as the sketch prefers, `eventId` is the upstream's stable ID.[^3] 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.[^3] Each attempt also has a 10-second deadline, set with `AbortSignal.timeout(10_000)` as in OpenAI's example.[^2]

## 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.[^3]

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.[^3] 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.[^6]

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

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`.[^2][^3]

## 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.[^2] You still need one to test your server, and you need a real one if you build your own agent host.

```javascript
// 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`".[^3] The Standard Webhooks specification gives similar advice.[^6]

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:

```text
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.[^3]

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.[^3] 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."[^3] 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`.[^8] 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.[^3]

## Step 3: run the full test

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

```bash
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:

```javascript
// 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](https://nerdleveltech.com/images/mcp-events-webhook-test-run.png)

What each group of checks shows:

| Checks | What was tested | Result |
|---|---|---|
| 2 | Subscribe with no `Authorization` header | `-32012 Forbidden` |
| 3 | Secret that decodes to 16 bytes | `-32602`, below the 24-byte minimum |
| 4 | Plain-HTTP public URL | `-32602` |
| 5, 6 | HTTPS 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` |
| 7 | Receiver holds a different secret | The receiver rejects the challenge's signature, so nothing is echoed: `-32015` with `challenge_failed` |
| 8, 9 | Subscribe, then repeat with `ttlMs: 120000` | Same `id`; `refreshBefore` moves to 2 minutes out |
| 10 | Alice subscribes to `doc_456` at the same URL | New `id`, 0 challenge POSTs |
| 11 | Bob subscribes with Alice's URL and arguments | New `id`, 1 challenge POST |
| 14 | Event for `doc_999` | No matching subscription, no delivery |
| 15 | Receiver returns `503` twice | Alice's delivery succeeds on attempt 3 |
| 16 | 300,194-byte event | Refused before sending, no retry |
| 19 | Body changed after signing | Receiver `400` |
| 22–24 | Unsubscribe 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`.[^3]

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.[^3]

## 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`.[^7]

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:

| Package | Source repository | Version (published) | `events/` strings | `tools/list` files |
|---|---|---|---|---|
| `@modelcontextprotocol/sdk` (npm) | modelcontextprotocol/typescript-sdk | 1.31.0 (Sept 28, 2026) | 0 | 22 |
| `@modelcontextprotocol/server` (npm) | modelcontextprotocol/typescript-sdk | 2.2.0 (Sept 28, 2026) | 0 | 10 |
| `@modelcontextprotocol/client` (npm) | modelcontextprotocol/typescript-sdk | 2.2.0 (Sept 28, 2026) | 0 | 12 |
| `@modelcontextprotocol/core` (npm) | modelcontextprotocol/typescript-sdk | 2.2.0 (Sept 28, 2026) | 0 | 6 |
| `mcp` (PyPI) | modelcontextprotocol/python-sdk | 2.2.0 (Sept 7, 2026) | 0 | 8 |
| `fastmcp` (PyPI; code ships in `fastmcp-slim`) | PrefectHQ/fastmcp | 4.0.10 (Sept 25, 2026) | 0 | 11 |
| `fastmcp` (npm) | punkpeye/fastmcp | 4.22.1 (Sept 30, 2026) | 0 | 13 |

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."[^4] 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."[^2][^3] 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."[^2][^3]
- **Validate callback addresses at connection time.** Check the resolved address inside the connection, as `post()` does, and never follow redirects.[^2][^3]
- **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.[^3]
- **Keep `eventId` stable across retries, sign every attempt again, and never retry `410` or `413`.**[^2][^3]
- **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."[^3] 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.[^3] 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."[^3] 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".[^3]

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."[^4] For long-running tool calls, see the [MCP Tasks extension tutorial](https://nerdleveltech.com/mcp-tasks-extension-tutorial).

For what the `2026-07-28` spec changed overall, see [MCP goes stateless](https://nerdleveltech.com/mcp-stateless-protocol-enterprise-authorization). Another Standard Webhooks integration, with a different signing pitfall, is covered in [Claude inference hooks](https://nerdleveltech.com/claude-inference-hooks-enterprise-dlp).

## 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.[^2]

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.[^3][^4][^5]

## FAQ

### What are MCP Events?

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.[^3]

### Does ChatGPT support MCP Events?

Yes, for plugin automations. OpenAI announced it at DevDay on September 29, 2026 and lists it as available to all plans.[^1][^9] ChatGPT supports webhook delivery and callback verification, requires protocol version `2026-07-28`, and does not support polling, streaming, or the `gap` and `terminated` control notifications.[^2]

### How do I verify an MCP Events webhook signature?

Compute HMAC-SHA256 over `webhook-id.webhook-timestamp.body` with the base64-decoded `whsec_` secret, and compare it with each `v1,` value in `webhook-signature`. Use the raw body bytes, and reject timestamps more than 5 minutes old.[^3][^6]

### Is MCP Events part of the official MCP specification?

Not yet. The design sketch is a draft proposal, and its incubation repository says its contents "do not represent official MCP specifications or recommendations."[^3][^5]

### Do the official MCP SDKs implement events/subscribe?

Not in the releases I checked on September 30, 2026. The official TypeScript packages (`@modelcontextprotocol/sdk` 1.31.0 and the 2.2.0 `server`, `client` and `core` packages) and the official Python SDK, `mcp` 2.2.0, contained none of the draft's `events/` method names. Neither did the third-party `fastmcp` packages on PyPI (4.0.10) and npm (4.22.1).[^7]

### What happens if my webhook receiver is down?

The server retries with exponential backoff on non-`2xx` responses and keeps the same `eventId`, so your receiver can de-duplicate. Retries are capped; an event whose retries run out is abandoned, and it can be recovered by cursor replay only when the upstream is durable.[^2][^3] In check 15, two `503` responses were followed by success on the third attempt.

## 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.[^1][^7]

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.

[^1]: [DevDay 2026 Recap — OpenAI](https://openai.com/index/devday-2026-recap/), published September 29, 2026, fetched 2026-09-30 ("MCP events for plugin automations"; "Available to all plans.").
[^2]: [MCP Events — Plugins, OpenAI Developers](https://developers.openai.com/plugins/build/mcp-events), 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).
[^3]: [MCP Events design sketch (docs/design-sketch-proposal.md) — modelcontextprotocol/experimental-ext-triggers-events on GitHub](https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md), status "Draft proposal", author Peter Alexander, dated 2026-02-19; raw file read on 2026-09-30.
[^4]: [Triggers and Events Charter — Model Context Protocol](https://modelcontextprotocol.io/community/working-groups/triggers-events), 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).
[^5]: [modelcontextprotocol/experimental-ext-triggers-events — GitHub](https://github.com/modelcontextprotocol/experimental-ext-triggers-events), raw README read on 2026-09-30.
[^6]: `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](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md), 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).
[^7]: 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.
[^8]: [json — JSON encoder and decoder, Python documentation](https://docs.python.org/3/library/json.html) (`ensure_ascii` defaults to true), confirmed locally with Python 3.10.12 on 2026-09-30.
[^9]: [OpenAI teases 20+ announcements at DevDay, watch live — 9to5Mac](https://9to5mac.com/2026/09/29/openai-teases-20-announcements-at-devday-watch-live/), published September 29, 2026, fetched 2026-09-30 ("It kicks off at 10 am PT/1 pm ET").
[^10]: [IANA IPv4 Special-Purpose Address Space registry](https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml), fetched 2026-09-30 (100.64.0.0/10, "Shared Address Space", Globally Reachable: False).
