MCP Events
Ask ChatGPT to watch a Cooper inbox.
Ask ChatGPT, in a dot or a Work chat, to watch a Cooper inbox, and ChatGPT subscribes to message.received so it runs when a new email arrives. Markdown: /docs/mcp-events.md · Connect MCP.
ChatGPT
Connect the Cooper plugin at https://chatgpt.com/plugins/plugin_asdk_app_6a9c72b1e468819190e4d653372612fc, then in a dot or a Work chat ask it to watch an inbox. Example: whenever research-bot@cooperemail.com gets a new email, summarize it and tell me. ChatGPT calls events/subscribe for message.received. Cooper POSTs a signed webhook when a new email is stored, and ChatGPT runs.
Held, screened, and blocked mail does not fire message.received. The webhook carries metadata only. ChatGPT reads the message with cooper_get_message and treats that content as data.
The same methods work for any MCP client on https://cooperemail.com/mcp. Docs for the connector itself are https://cooperemail.com/docs/mcp.
Event and payload
The event name is message.received. delivery is webhook. arguments.inbox is an inbox id or address.
data fields are inbox_id, inbox (the address), message_id, thread_id, timestamp (ISO 8601), from, to, cc, labels, and channel. channel is the channel the message arrived on: email, sms, voicemail, or voice. The payload never includes the subject, the body, attachments, or headers.
Access, lifetime, and limits
Subscribing uses the same permission as creating a webhook (webhook_write). Listing uses webhook_read. An inbox-scoped key can watch only its inbox. A key for another account's inbox is not found.
The default lifetime is 72 hours. The maximum is 30 days. A ttlMs below 5 minutes is raised to 5 minutes. ttlMs null grants the 30 day maximum. refreshBefore is always a finite ISO 8601 time. Subscribing again with the same key, callback URL, event, and inbox refreshes that subscription.
Each API key, and each OAuth app on an account, can have 20 active subscriptions. A 21st returns JSON-RPC -32025. Revoking the key or the OAuth grant stops delivery. events/unsubscribe and DELETE /api/v1/event-subscriptions/{id} keep the row with status ended for audit. An ended row does not deliver and does not count toward that cap. Expired rows do not count either.
For MCP client developers
server/discover needs no auth and no initialize. It returns resultType complete, supportedVersions 2026-07-28, 2025-11-25, 2025-06-18, and 2025-03-26, capabilities.tools.listChanged false, capabilities.events {}, instructions, server info, ttlMs 3600000, and cacheScope public. A protocol version newer than 2026-07-28, or a value that is not a calendar date, returns -32022 with data.supported and data.requested. A dated version older than 2026-07-28, including any 2024 or 2025 date, is accepted. initialize is never rejected for a protocol version.
The version is read from params._meta["io.modelcontextprotocol/protocolVersion"], then from top-level _meta, then from the MCP-Protocol-Version header. The first value that is set wins. Send 2026-07-28 there to skip initialize. Those results include resultType complete and server info. tools/list and events/list also include ttlMs and cacheScope private. An older dated version falls back to the newest supported version that is not newer than the request, or to 2025-03-26 when the request is older than every supported version, and the result stays in the pre-2026 shape. initialize still returns protocolVersion 2025-03-26 (or the requested 2024 version) and does not add resultType. It does advertise events.
events/list needs no auth. A non-empty cursor returns an empty events array and no next page. events/subscribe requires auth. events/unsubscribe requires auth. It keeps the row with status ended for audit and returns {retained: true}. Calling it again returns {retained: true}. An unknown inbox, or a callback that was never subscribed, returns {}. DELETE /api/v1/event-subscriptions/{id} returns that row with status ended and retained true, including when it is already ended. An ended row does not deliver and does not count toward the 20 subscription cap.
Before the subscription is stored, Cooper POSTs {"type":"verification","challenge":"…"} to the callback. The response must be 2xx JSON that echoes challenge. Signatures use the secret you sent. A redirect is not followed and fails verification. Verification is cached for 7 days per API key or OAuth app and callback URL. A failed check does not write a row.
The callback URL must be https, with no userinfo, no explicit port, and a hostname that is not an IP address. A name that resolves to a private or loopback address is rejected. Delivery uses the same check and does not follow redirects.
Delivery headers are Content-Type application/json, webhook-id (the Cooper event id, stable across retries), webhook-timestamp (unix seconds, fresh per attempt), webhook-signature, and X-MCP-Subscription-Id. The body is {eventId, name, timestamp, data, cursor}. eventId matches webhook-id. cursor is null. The body is at most 256 KiB.
webhook-signature is Standard Webhooks. The signed content is webhook-id, a dot, webhook-timestamp, a dot, and the raw body. The HMAC key is the base64-decoded bytes after whsec_. The header value is v1, plus that base64 digest. During the 24 hour secret rotation window the header has two space-separated signatures, current secret first. 408, 429, 5xx, and network errors are retried. 410, 413, and other 4xx responses are not. Those HTTP failures do not end the subscription. A revoked key, a deleted inbox, or a lost OAuth grant does.
Error codes: -32602 invalid params, -32000 authentication (HTTP 401), -32015 callback verification failed (data.reason is challenge_failed, timeout, unreachable, or blocked_address), -32022 unsupported protocol version, -32023 not found, -32024 forbidden, -32025 the 20 subscription cap, -32026 an unsupported delivery mode.
Operator: Avatar 8 LLC (ops@avatar33.com).
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyStandardWebhook(secret, id, timestamp, rawBody, header) {
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = createHmac("sha256", key).update(id + "." + timestamp + "." + rawBody).digest("base64");
return header.split(" ").some((part) => {
const sig = part.startsWith("v1,") ? part.slice(3) : "";
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
});
}