Make poll a mandatory delivery mode so every events-capable server is reachable by every client (push/webhook remain optional optimizations). Add a Webhook Delivery Profile subsection with WAF-relevant invariants (POST-only, required headers, 256 KiB body SHOULD, TLS termination) and defer User-Agent to SEP-1336 and egress-IP discovery to SEP-2127. Incorporate further fixes from adversarial review.
77 KiB
MCP Events — Design Sketch
Status: Draft proposal Authors: Peter Alexander Date: 2026-02-19
Summary
Add an Events primitive to MCP that allows clients to subscribe to things happening in the outside world (new email, Slack message, devops alert, scheduled reminder, etc.) and react to them. The design supports three delivery mechanisms — client-side polling, server-side push over an existing connection, and webhook delivery to an external endpoint — with a unified subscription API that abstracts the difference from application code.
Key design principles:
- Poll is mandatory; push and webhook are optional optimizations. Every event type a server lists MUST support poll, so any events-capable client can consume any event type. Push and webhook are opt-in modes that offer lower latency or connection-free delivery; servers advertise them per event type and clients use them when both sides support them. This guarantees a universal fallback and avoids fragmenting the ecosystem into incompatible delivery-mode islands.
- SDK-level polling, not LLM-level polling. The client SDK drives the polling loop without burning LLM inference tokens. The LLM is only invoked when events actually arrive.
- No durable subscription state. Poll holds no protocol-required subscription state — each request is self-describing. Push scopes subscription state to connection lifetime. Webhook uses soft state with mandatory TTL — the server holds subscriptions in memory, but they expire automatically if the client stops refreshing. The client is always the source of truth across all three modes. (This principle is about protocol-required state. SDKs MAY hold ephemeral derived state — a poll-lease table for lifecycle hooks, an emit ring buffer — but it is reconstructable from subsequent client requests, never persisted, and never owed to any particular client. Servers that relay from upstream sources will also hold upstream credentials, upstream webhook registrations, etc., all outside the protocol's concern.)
- Client owns subscription state. In all modes, the client holds the canonical list of subscriptions. For poll and push, the server holds no protocol-required subscription state. For webhook, the server holds TTL-scoped subscription records, but the client drives their lifecycle via periodic refresh.
- Event payloads are untrusted data. The spec must be explicit that event payloads carry the same injection risks as tool results.
- Servers own delivery. Unlike traditional pub/sub (Kafka, SNS) where a shared broker decouples producers from subscribers, MCP servers run as independent processes with no central infrastructure. Each server handles event delivery itself, with the SDK absorbing the delivery complexity. In the common case, the server is a relay — ingesting events from an upstream system (GitHub webhooks, a Slack socket, a message bus) and routing them to subscribed clients — rather than the originating producer. Deployments that want a decoupled model can use a dedicated MCP server as a broker — no protocol changes required.
Capability Declaration
Servers advertise event support in their capabilities:
{
"capabilities": {
"events": {
"listChanged": true
}
}
}
Listing Available Events
Request: events/list
// params (optional)
{ "cursor": "..." } // pagination
Response
{
"events": [
{
"name": "email.received",
"description": "Fires when a new email arrives in the inbox",
"delivery": ["poll"],
"inputSchema": {
"type": "object",
"properties": {
"from": { "type": "string", "description": "Glob pattern for sender address" },
"subject_contains": { "type": "string" },
"redact_pii": { "type": "boolean", "default": false, "description": "Strip PII from event payloads" },
"include_body_preview": { "type": "boolean", "default": true, "description": "Include a snippet of the email body" }
}
},
"payloadSchema": {
"type": "object",
"properties": {
"messageId": { "type": "string" },
"from": { "type": "string" },
"subject": { "type": "string" },
"receivedAt": { "type": "string", "format": "date-time" }
}
}
},
{
"name": "incident.created",
"description": "Fires when a new PagerDuty incident is created",
"delivery": ["webhook", "push", "poll"],
"inputSchema": {
"type": "object",
"properties": {
"severity": { "type": "string", "enum": ["P1", "P2", "P3", "P4"] },
"service": { "type": "string" },
"deduplicate_window_seconds": { "type": "integer", "default": 0, "description": "Suppress duplicate alerts within this window" }
}
},
"payloadSchema": { "..." : "..." }
}
]
}
Notes:
deliverylists the delivery modes this event type supports. It MUST include"poll"and MAY additionally include"push"and/or"webhook". Poll is the universal fallback; push and webhook are optional optimizations.inputSchemais a JSON Schema describing valid subscription parameters — these may include filters (which narrow the event stream), transforms (which modify payloads), or other server-defined configuration. This mirrors theinputSchemaon tools for consistency.payloadSchemadescribes the shape ofdatain delivered events.
Dynamic Event Types: notifications/events/list_changed
If the set of available event types changes at runtime (e.g., a plugin is loaded, a data source is connected), the server sends a notifications/events/list_changed notification. The client SHOULD re-call events/list to refresh its event type registry. This is consistent with notifications/tools/list_changed and notifications/resources/list_changed.
Subscribing and Event Delivery
There are three delivery modes with different subscription mechanisms:
- Poll mode: Client calls
events/pollwith event name, params, and cursor. No separate subscribe step needed — the first poll with a null cursor bootstraps the subscription. Server holds no protocol-required state (the SDK MAY track an ephemeral poll lease for lifecycle hooks; see Unsubscribe timing by mode). - Push mode: Client opens a long-lived
events/streamrequest per subscription. Events are delivered on the SSE response stream (HTTP) or as notifications on stdout (stdio). Closing the request stream terminates the subscription. Server state is scoped to request lifetime. - Webhook mode: Client calls
events/subscribeto register a callback URL. The server POSTs events to that URL as they occur. Subscriptions have a mandatory TTL — the client must periodically refresh by re-callingevents/subscribebefore the TTL expires. If the client stops refreshing, the subscription expires and the server reclaims resources. Designed for remote servers where maintaining a long-lived connection is impractical.
Error Codes
| Code | Message | Meaning |
|---|---|---|
-32602 |
InvalidParams |
Params don't match inputSchema (standard JSON-RPC invalid params) |
-32011 |
EventNotFound |
Unknown event name |
-32012 |
Unauthorized |
User lacks permission for this event/params combination |
-32013 |
TooManySubscriptions |
Server-imposed subscription limit reached |
-32014 |
CursorExpired |
Cursor is no longer valid (upstream compacted, server reset); client must re-subscribe with cursor: null |
-32015 |
InvalidCallbackUrl |
Webhook URL is unreachable or rejected by the server (webhook mode only) |
-32016 |
SubscriptionNotFound |
Unknown subscription ID for events/unsubscribe (webhook mode only) |
-32017 |
DeliveryModeUnsupported |
Event type does not support the requested delivery mode |
These codes occupy -32011..-32017 to avoid collision with base MCP error codes (e.g., -32002 ResourceNotFound).
Poll-Based Delivery
The client SDK calls events/poll at the server-recommended interval. This is a protocol-level operation, NOT an LLM tool call.
sequenceDiagram
autonumber
participant SDK as Client SDK
participant Server as MCP Server
loop every nextPollSeconds
SDK->>Server: events/poll {id, name, params, cursor}
Server-->>SDK: {events[], cursor, nextPollSeconds, hasMore}
end
Note over SDK: LLM invoked only when<br/>events[] is non-empty
Request: events/poll
Each events/poll request carries one subscription. A client with multiple subscriptions runs one poll loop per subscription.
{
"id": "sub_email", // client-provided routing identifier
"name": "email.received",
"params": {
"from": "*@anthropic.com",
"redact_pii": true
},
"cursor": null, // null = start from now
"maxEvents": 50 // optional; cap events returned
}
Response
{
"id": "sub_email",
"events": [
{
"eventId": "evt_001",
"name": "email.received",
"timestamp": "2026-02-19T15:30:00Z",
"data": {
"messageId": "msg_xyz",
"from": "dsp@anthropic.com",
"subject": "MCP spec review",
"receivedAt": "2026-02-19T15:29:58Z"
}
}
],
"cursor": "historyId_99842",
"hasMore": false,
"nextPollSeconds": 30
}
EventOccurrence schema
Each entry in events[] (and the body of push/webhook deliveries) is an EventOccurrence:
| Field | Type | Required | Description |
|---|---|---|---|
eventId |
string | yes | Stable identifier for deduplication |
name |
string | yes | Event type name |
timestamp |
string (ISO 8601) | yes | When the event occurred |
data |
object | yes | Payload conforming to the event type's payloadSchema |
cursor |
string | no | Subscription position after this event (push/webhook only; poll carries cursor at the response level) |
Notes:
idis a client-provided routing identifier. It is opaque to the server and echoed back in responses so the client can route results to the correct handler. It carries no uniqueness constraint — a client MAY reuse the sameidfor several subscriptions to fan results into a single handler.cursoris opaque to the client. The client stores it and passes it back on the next poll. Anullcursor means "start from now" — the server returns no events and provides a fresh cursor for subsequent polls.eventIdenables client-side deduplication across polls (e.g., after a crash/restart). It is server-assigned: when the upstream source provides a stable event identifier (Stripeevt_*, GitHub delivery GUID, Kafka offset, Gmail message ID), the server SHOULD use that value aseventIdso that the same upstream event surfaced via multiple paths (e.g., webhook emit and poll backfill) carries the sameeventIdand dedup works. The SDK auto-generates aneventIdonly when the author supplies none.maxEventsis an optional cap on the number of events returned. If more events are available than the limit, the server returns a partial batch with an intermediate cursor and setshasMore: true. The client SHOULD poll again immediately (ignoringnextPollSeconds) to drain the backlog. If omitted, the server uses its own default limit.hasMoreindicates whether additional events are available beyond the returned batch. Whentrue, the client should poll again immediately with the updated cursor. Whenfalse, the client waitsnextPollSecondsbefore the next poll.nextPollSecondsallows the server to dynamically adjust polling frequency (e.g., back off when rate-limited upstream, speed up when activity is detected). Ignored whenhasMoreistrue. Clients SHOULD apply a configurable floor (default 1 second) to guard against a misbehaving server inducing a tight loop.- Empty
eventsarray means nothing happened — this is the common case and should be cheap. - The server holds no protocol-required per-client subscription state. Each poll request is self-contained: the client provides the event name, params, and cursor. The server does not need to "remember" previous poll requests to answer them. (The SDK MAY hold ephemeral derived state — a poll-lease table to drive
on_subscribe/on_unsubscribe, and for emit-only event types a ring buffer of recent events — but neither is required to answer a poll, both are reconstructable, and neither is owed to any particular client. See Unsubscribe timing by mode and Emit-only event types under Server SDK Guidance.) - Errors (
EventNotFound,Unauthorized,CursorExpired,InvalidParams) are returned as a standard JSON-RPC error response for the request — there is no partial-success model since each request carries one subscription.
Push-Based Delivery
Push delivery uses a long-lived events/stream request — one per subscription. The client opens a stream for each subscription and the server delivers events as they occur. The request is a standard JSON-RPC request with an id, which enables cancellation via notifications/cancelled and is echoed in every notification for routing.
The transport mechanism differs by transport type:
- Streamable HTTP: The
events/streamrequest is a POST that returns an SSE response stream. This stream carries event notifications (notifications/events/*); it is independent of, and does not replace, the transport's existing GET-based SSE stream, which continues to carry non-event server-initiated notifications (notifications/tools/list_changed, progress, logging, etc.). The client cancels by aborting the request stream (TCP close on HTTP/1.1,RST_STREAMon HTTP/2) — no explicit cancellation message is needed. - stdio: The
events/streamrequest is sent on stdin. The server delivers events as JSON-RPC notifications on stdout. Since there is no connection to close, the client cancels by sendingnotifications/cancelledwith the request'sid.
sequenceDiagram
autonumber
participant SDK as Client SDK
participant Server as MCP Server
SDK->>Server: events/stream {id, name, params, cursor}
activate Server
Server-->>SDK: notifications/events/active {requestId, id, cursor}
loop as events occur
Server-->>SDK: notifications/events/event {requestId, id, event, cursor}
end
SDK->>Server: notifications/cancelled {requestId} (stdio)<br/>— or connection close (Streamable HTTP)
Server-->>SDK: StreamEventsResult (final frame)
deactivate Server
Request: events/stream
// Streamable HTTP: POST /mcp
// stdio: written to stdin
{
"jsonrpc": "2.0",
"method": "events/stream",
"id": 1,
"params": {
"id": "sub_email",
"name": "email.received",
"params": { "from": "*@anthropic.com", "redact_pii": true },
"cursor": null
}
}
Event Delivery
If the subscription is invalid (EventNotFound, Unauthorized, InvalidParams, DeliveryModeUnsupported), the server responds immediately with a JSON-RPC error and no stream is opened. Otherwise the server confirms the subscription and delivers events as notifications. Every notifications/events/* message carries requestId — the JSON-RPC id of the parent events/stream request — so a client with multiple concurrent streams (especially on stdio, where all share one stdout) can route notifications to the correct stream.
// Confirmation
{"jsonrpc":"2.0","method":"notifications/events/active","params":{"requestId":1,"id":"sub_email","cursor":"historyId_99840"}}
// Events as they occur
{"jsonrpc":"2.0","method":"notifications/events/event","params":{"requestId":1,"id":"sub_email","eventId":"evt_001","name":"email.received","timestamp":"2026-02-19T15:30:00Z","data":{"messageId":"msg_xyz","from":"dsp@anthropic.com","subject":"MCP spec review"},"cursor":"historyId_99842"}}
// Transient per-occurrence error (stream stays open)
{"jsonrpc":"2.0","method":"notifications/events/error","params":{"requestId":1,"id":"sub_email","error":{"code":-32014,"message":"CursorExpired"}}}
// Final frame when stream closes (StreamEventsResult)
{"jsonrpc":"2.0","id":1,"result":{"_meta":{}}}
notifications/events/error reports a transient failure for the subscription (e.g., a single upstream fetch failed, or the cursor expired); the subscription remains active on the stream and the server keeps delivering subsequent events. Only notifications/events/terminated (see Authorization) ends the subscription.
On Streamable HTTP, notifications are SSE data: frames; when the server terminates the stream it sends the StreamEventsResult as the final data: frame, but when the client terminates by aborting the request stream no result can be sent (see Cancellation). On stdio, notifications are newline-delimited JSON messages on stdout.
The events/stream response carries only notifications/events/* messages. Non-event MCP notifications (notifications/tools/list_changed, notifications/resources/updated, progress, logging) continue to use the transport's existing channels and are unaffected by this proposal.
Lifecycle
- Stream termination. The
StreamEventsResultis an empty typed result ({"_meta": {}}). It carries no information — it satisfies JSON-RPC's requirement that every request gets a response. All meaningful content is in the preceding notifications. It is sent whenever the server can write a final frame: on Streamable HTTP only when the server initiates the close; on stdio the server MAY send it, and clients MUST NOT depend on receiving it. - Heartbeat. The server MUST send periodic keepalive messages on the push stream so the client can distinguish "nothing to send" from "connection is dead." On Streamable HTTP, this is an SSE comment (
: keepalive\n\n). On stdio, this is{"jsonrpc":"2.0","method":"notifications/events/heartbeat","params":{"requestId":1}}. The server SHOULD send a heartbeat at least every 30 seconds. Clients SHOULD NOT apply their default request timeout toevents/stream; the heartbeat is the liveness signal, and a client that has received neither an event nor a heartbeat for more than twice the heartbeat interval SHOULD treat the stream as dead and reconnect with its cursor. - Cancellation. On Streamable HTTP, the client aborts the request stream. On stdio, the client sends
notifications/cancelledwith therequestIdmatching theevents/streamrequest'sid. In both cases, the server MUST stop delivering events and release any associated resources. On stdio, the server MAY then send theStreamEventsResult; base MCP says servers SHOULD NOT respond to cancelled requests, so omitting it is the expected behaviour. On Streamable HTTP, the abort is the terminal signal and no result is sent. - Concurrent streams. A client MAY hold multiple concurrent
events/streamrequests open, one per subscription. To add a subscription, open an additional stream; to remove one, cancel only its stream. On HTTP/1.1 each stream consumes a TCP connection, so clients with many subscriptions effectively depend on HTTP/2 multiplexing; on stdio, concurrent streams share stdout and are demultiplexed viarequestId. - Reconnection after failure. If the connection drops (HTTP) or the server stops sending (stdio), the client sends a new
events/streamwith the same subscription and its last-known cursor.
Cursor Advancement
Each event notification on the push stream includes a cursor field. This cursor represents the subscription's position after this event. The client tracks the latest cursor per subscription for use during reconnection.
Webhook-Based Delivery
Webhook delivery is for remote MCP servers where maintaining a long-lived connection (push) or frequent polling is impractical. The server POSTs events to a callback URL provided by the client. Webhook subscriptions use soft state with mandatory TTL — the server holds them in memory, but they expire automatically if the client stops refreshing.
The callback URL does not need to be the client itself. A common deployment is a forward proxy that receives webhooks and serves events to clients via poll or push:
Upstream → MCP Server → webhook POST → Forward Proxy ← Client (poll or push)
sequenceDiagram
autonumber
participant SDK as Client SDK
participant Server as MCP Server
participant Hook as Webhook Endpoint
SDK->>Server: events/subscribe {id, name, params, delivery: {url}, cursor}
Server-->>SDK: {secret, refreshBefore}
loop as events occur
Server->>Hook: POST {id, event, cursor} + HMAC signature
Hook-->>Server: 200 OK
Hook--)SDK: deliver event (implementation-defined,<br/>e.g. forward proxy → poll/push)
end
loop before each refreshBefore
SDK->>Server: events/subscribe (same key — refreshes TTL)
Server-->>SDK: {refreshBefore'}
end
opt explicit teardown (else: stop refreshing → TTL expiry)
SDK->>Server: events/unsubscribe {id, delivery: {url}}
Server-->>SDK: (ack)
end
Subscribing: events/subscribe
Unlike poll and push, webhook delivery requires an explicit subscribe step because the server needs to know where to POST events. events/subscribe is idempotent — calling it again with the same subscription key (see Subscription Identity below) refreshes the TTL and updates mutable fields. This is the mechanism clients use to keep subscriptions alive.
{
"jsonrpc": "2.0",
"method": "events/subscribe",
"id": 2,
"params": {
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "incident.created",
"params": { "severity": "P1" },
"delivery": {
"mode": "webhook",
"url": "https://proxy.example.com/hooks/client123"
},
"cursor": null
}
}
// Response
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"secret": "whsec_5c8f...", // present when subscription is (re)created
"refreshBefore": "2026-02-19T16:30:00Z"
}
Notes:
events/subscribeis ONLY used for webhook delivery. Poll and push do not need it.idis a client-generated, high-entropy identifier for the logical subscription. It MUST contain at least 122 bits of entropy (e.g., a UUIDv4). See Subscription Identity below.secretis a shared secret for HMAC-SHA256 signature verification of webhook deliveries. The server generates it when the subscription is created and returns it in the response; it is not returned on subsequent refreshes of an existing subscription. If the server has lost the subscription (restart, TTL expiry) the refresh creates a new one, and a freshsecretappears in the response — the client MUST check for its presence on every refresh and update the verifier accordingly. The client MAY supplydelivery.secretin the request to override server generation (e.g., when the secret is provisioned out-of-band in a vault); servers SHOULD accept this but are not required to.cursor(request only) tells the server where to begin delivery.nullmeans "start from now." A non-null value requests replay from that position (honoured when the event type is backed by a durable upstream). The cursor is client-owned: the server does not persist a cursor position (the response does not include one), though it computes a safe-to-persist watermark for inclusion in each delivered payload (see Webhook Event Delivery). The client persists thatcursorand supplies it on every refresh. If the subscription is live, the supplied cursor is at or behind the server's in-flight position and the server treats it as a no-op (delivery continues uninterrupted). If the subscription has lapsed or the server has restarted, the cursor becomes the replay point. This means clients use a single rule — always pass the last-persisted cursor — and the server is idempotent under it.refreshBeforeis mandatory. It is an ISO 8601 timestamp indicating when the subscription will expire. The client MUST re-callevents/subscribewith the same subscription key before this time to keep the subscription alive. The server resets the TTL on each refresh.events/subscribeis idempotent within the caller's subscription scope (see Subscription Identity). If a subscription with the same scoped key exists, the server resets the TTL and updates mutable fields in place. If the subscription has expired — or the server has restarted and lost it — the server creates a fresh subscription using the provided cursor.- The server holds subscription state (id, event name, params, callback URL, secret) in memory with TTL. No durable storage is required — if the server restarts, clients will re-subscribe on their next refresh cycle. For event types backed by a durable upstream, the client's persisted cursor recovers any events that occurred during the gap; for emit-only event types, events during the gap are not recoverable (see Emit-only event types).
Subscription Identity
Webhook subscriptions are keyed by a compound subscription key that determines which events/subscribe calls refer to the same logical subscription. The server uses this key for idempotent upsert on subscribe, for lookup on unsubscribe, and to isolate tenants from one another.
Key composition. The subscription key is:
- On servers that authenticate the caller:
(principal, delivery.url, id). - On servers without caller authentication:
(delivery.url, id).
where principal is the server's canonical identifier for the authenticated subject (e.g., OAuth sub, API key ID, service-account name) and delivery.url is the callback URL exactly as supplied by the client. delivery.url is part of the key in both scopes and is therefore immutable for the lifetime of a subscription — to migrate delivery to a new endpoint, the client MUST events/unsubscribe the old subscription and events/subscribe a new one. Servers that support both authenticated and anonymous access MUST select the scoping mode per-request: include principal whenever one is present. A subscription created under one scope is not visible under the other.
id requirements. The id field MUST be a high-entropy value containing at least 122 bits of randomness; a UUIDv4 is RECOMMENDED. The client MUST generate id once per logical subscription and persist it for the subscription's lifetime — a fresh id on each subscribe call creates a new subscription rather than refreshing the existing one. SDKs SHOULD generate and persist id on the client's behalf and SHOULD NOT expose an interface that encourages hand-picked low-entropy values.
Capability semantics (unauthenticated). When the server does not authenticate callers, knowledge of (delivery.url, id) is sufficient to refresh, modify, or delete the subscription. The id therefore functions as a bearer capability and SHOULD be treated as confidential: clients SHOULD NOT log it at default verbosity, embed it in URLs, or expose it to untrusted intermediaries. Servers MAY reject id values that are obviously low-entropy (shorter than 16 bytes, dictionary words, sequential integers) to guard against misconfigured clients.
Mutable vs. immutable fields. name, params, and delivery.url are immutable for a subscription's lifetime: a refresh that supplies different values for any of them addresses a different subscription (the server treats it as a create, not an update). To change what a subscription listens for or where it delivers, the client MUST events/unsubscribe the old one and events/subscribe a new one. This avoids the case where name/params change in place but the upstream listener provisioned by on_subscribe remains bound to the old values.
On an idempotent subscribe against an existing key, the server updates the remaining fields as follows:
| Field | Behavior on existing subscription |
|---|---|
delivery.secret |
Replaced if supplied (client-driven rotation / override). If omitted, the existing secret is unchanged and is not returned. |
cursor |
The server treats the supplied value as the client's last-persisted position. If the subscription is live and the cursor is at or behind the current in-flight position, this is a no-op. If the subscription has lapsed or the server restarted, delivery (re)starts from this position. The server does not store this value beyond initiating delivery. |
| TTL | Reset. |
active |
Set to true. A successful refresh is the client's liveness signal; if delivery had been suspended (active: false in deliveryStatus) due to repeated failures, the server resumes retrying pending events. |
Cross-tenant isolation. Because the subscription key always includes delivery.url (and principal when authenticated), two distinct tenants cannot collide on id alone. A malicious caller who learns another tenant's id but not their principal credentials or callback URL cannot refresh, modify, or delete that tenant's subscription, nor redirect its deliveries.
Webhook Event Delivery
The server POSTs events to the callback URL as they occur:
POST https://proxy.example.com/hooks/client123
Content-Type: application/json
X-MCP-Signature: sha256=<hex-lowercase HMAC-SHA256(secret, timestamp + "." + body)>
X-MCP-Timestamp: 1739980800
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"eventId": "evt_789",
"name": "incident.created",
"timestamp": "2026-02-19T16:00:00Z",
"data": {
"incidentId": "INC-1234",
"title": "Database connection pool exhausted",
"severity": "P1"
},
"cursor": "cursor_xyz"
}
Notes:
X-MCP-Signaturecontains an HMAC-SHA256 signature (lowercase hex) computed overtimestamp + "." + bodyusing the shared secret from subscription. The receiver MUST verify this before processing. The receiver readsidfrom the (unverified) JSON body to look up the correct secret, then verifies the body against it; reading an unverified field for key selection is safe because a tamperedidsimply causes verification to fail. TheX-MCP-Timestampheader contains the Unix timestamp (seconds) of the request; the receiver SHOULD reject deliveries older than 5 minutes to prevent replay attacks. Each retry attempt regenerates the timestamp and signature.eventIdin the body enables idempotent processing. The receiver SHOULD deduplicate by this value.cursorin the body is a safe-to-persist watermark: it represents a position such that every event at or before it has been acknowledged by the endpoint or abandoned by the server. The server computes this from its in-memory retry queue — it does not includecursor_Nin event N's payload until events at positions< Nhave been acked or given up on. The client persists every cursor it receives; the most recently received value is always safe to supply on refresh. The endpoint MUST makecursorandeventIdavailable to the consuming client by whatever channel it uses to forward events; cursor-based recovery on resubscribe depends on the client receiving and persisting this value.- Delivery model. The server retries each event independently with exponential backoff on non-
2xxresponses. This matches the dominant webhook convention (Stripe, GitHub, Shopify, the Standard Webhooks spec). Concurrent deliveries and retries may therefore arrive out of order; the receiver useseventIdfor deduplication andtimestampfor ordering if needed. Because the payloadcursoris a watermark (not the event's own position), out-of-order arrival does not cause the client to persist an unsafe cursor. - Acknowledgement semantics. A
2xxresponse from the webhook endpoint signals that the event has been accepted and the server need not retry it. The endpoint SHOULD NOT return2xxuntil the event has been durably persisted or forwarded — an endpoint that ACKs and then loses the event leaves recovery dependent on the client's last-persisted cursor, which may predate the lost event. At-least-once delivery in webhook mode holds between server and endpoint; end-to-end delivery to the agent depends on the endpoint honouring this contract. - Subscribe/delivery race. Endpoint verification (see Webhook Security) provides a natural barrier: the server does not begin delivering events until the endpoint has echoed the challenge, by which time the client has the
secretfrom theevents/subscriberesponse. A receiver that nonetheless gets a delivery for anidit does not yet recognise, or that it cannot yet verify, SHOULD return a retryable status (e.g.,503or425 Too Early) rather than dropping it; the server's normal retry/backoff machinery will redeliver once the client is ready.eventIddeduplication and cursor replay make this safe. - After repeated failures (server-defined threshold), the server MAY suspend delivery (
deliveryStatus.active: false). A subsequent successful refresh reactivates it (setsactive: true) and the server resumes retrying pending events; if the client never refreshes, the subscription expires naturally at TTL.
Webhook Delivery Status
The events/subscribe response MAY include a deliveryStatus object when refreshing an existing subscription. This lets the client detect delivery problems without a separate monitoring channel:
// Healthy subscription refresh
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"refreshBefore": "2026-02-19T17:00:00Z",
"deliveryStatus": {
"active": true,
"lastDeliveryAt": "2026-02-19T16:28:00Z",
"lastError": null
}
}
// Subscription with delivery failures
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"refreshBefore": "2026-02-19T17:00:00Z",
"deliveryStatus": {
"active": false,
"lastDeliveryAt": "2026-02-19T15:45:00Z",
"lastError": "http_4xx",
"failedSince": "2026-02-19T15:50:00Z"
}
}
deliveryStatus is OPTIONAL — servers MAY omit it entirely. When present, active indicates whether the server is currently delivering events (false means it has suspended retries after repeated failures; the refresh that returned this status has just reactivated it). lastError MUST be a server-generated category string — one of connection_refused, timeout, tls_error, http_4xx, http_5xx, challenge_failed — and MUST NOT include raw response bodies, headers, or status lines from the endpoint, to avoid serving as a response oracle for attacker-chosen URLs. The client can use this information to diagnose connectivity or authentication issues with the webhook endpoint.
Webhook Security
SSRF prevention. The server MUST validate callback URLs. Servers SHOULD reject URLs whose resolved IP is not globally routable per the IANA IPv4 and IPv6 Special-Purpose Address Registries (illustrative examples: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1, fc00::/7, fe80::/10) unless explicitly configured to allow them. To prevent DNS rebinding, this validation MUST be performed at delivery time, not only at subscribe time: the server resolves the hostname, checks the resolved IP against the blocklist, and connects directly to that validated IP (sending the original hostname in the Host header / TLS SNI) so the address cannot change between check and connect. Subscribe-time validation alone is insufficient — a rebinding attacker returns a public IP at subscribe time and a private IP at delivery time. Webhook delivery requests MUST NOT follow HTTP redirects, since a redirect can target an internal address that bypasses the blocklist. Servers MAY additionally maintain an allowlist of permitted callback URL patterns.
Endpoint verification. Before activating delivery for a newly created subscription, the server MUST verify that the subscriber controls delivery.url. The server POSTs a verification request to the URL:
POST https://proxy.example.com/hooks/client123
Content-Type: application/json
X-MCP-Signature: sha256=<...>
X-MCP-Timestamp: 1739980800
{"type":"verification","id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","challenge":"9f86d081884c7d65"}
The endpoint MUST respond 200 OK with body {"challenge":"9f86d081884c7d65"} (echoing the token exactly). The server MUST NOT begin delivering events until verification succeeds, and SHOULD fail the events/subscribe call with -32015 InvalidCallbackUrl if verification does not succeed within a short timeout. Verification is required only on creation, not on refresh of an existing subscription. This prevents a caller from directing the server to POST (and retry) events at an arbitrary third-party endpoint they do not control — without it, the server is a confused-deputy amplification vector.
TLS requirement. Callback URLs SHOULD use https://. Event payloads transit in cleartext over plain http://, exposing them to interception. Servers MAY reject non-TLS callback URLs.
Replay attack prevention. The webhook POST MUST include an X-MCP-Timestamp header containing the Unix timestamp (seconds) at which the request was generated. The HMAC signature MUST cover both the timestamp and the body and is sent as X-MCP-Signature: sha256=<hex-lowercase> where the value is HMAC-SHA256(secret, timestamp + "." + body) encoded as lowercase hexadecimal. In this formula, body is the raw HTTP request body bytes exactly as received, before any parsing or re-serialization; the timestamp + "." prefix is encoded as UTF-8; and secret is the literal whsec_... string encoded as UTF-8 bytes. Receivers MUST compute the HMAC over the raw body, never over a re-serialized JSON object. Each retry attempt MUST regenerate the timestamp and signature so that retries are not rejected by the receiver's freshness window. The receiver SHOULD reject deliveries where the timestamp is more than 5 minutes old. This prevents captured webhook payloads from being replayed. This scheme is structurally compatible with Standard Webhooks; see Open Question 4 on whether to adopt its header names directly.
Secret generation. The signing secret is generated by the server. This matches the dominant webhook convention (Stripe, Slack, Shopify, the Standard Webhooks spec): the party that signs deliveries owns the key, which guarantees adequate entropy, per-subscription uniqueness, and unilateral rotation. Client-supplied secrets (the GitHub model) are supported as an optional override via delivery.secret for deployments that pre-provision secrets in a vault, but are not the default.
Secret rotation. A client that needs to force rotation calls events/subscribe with the same subscription key and supplies a new delivery.secret; the idempotent upsert replaces it atomically alongside the TTL refresh. Server-initiated rotation happens implicitly whenever the server (re)creates the subscription — the new secret appears in the response and the client adopts it. On unauthenticated servers, the ability to rotate is gated solely by knowledge of (delivery.url, id) — see Subscription Identity for the capability implications.
Authentication to the callback endpoint. HMAC is the only authentication mechanism the protocol defines between the MCP server and the callback endpoint. This matches the dominant pattern for product webhooks (Stripe, GitHub, Slack, Twilio all authenticate deliveries with HMAC alone) and keeps the server's per-subscription credential surface to a single secret.
HMAC is verified by application code after the request has been routed. It does not help if the callback URL sits behind infrastructure that requires transport-level auth before routing — an API gateway that demands Authorization: Bearer, a cloud endpoint requiring a platform-issued identity token, an mTLS-only mesh. For those deployments, point the callback at a forward proxy under the tenant's control: the proxy presents a public endpoint to the MCP server, terminates HMAC, and re-authenticates to downstream services using whatever mechanism the internal network requires. The protocol does not provide a way to pass through bearer tokens, perform an OAuth client-credentials grant, or attach OIDC identity tokens on the tenant's behalf; these may be considered for a future revision if deployment experience warrants it.
Delivery profile (for WAF / private-cloud deployments). Gateways are commonly fronted by a WAF, API gateway, or ingress controller that needs static rules to admit legitimate deliveries. The following invariants are intended to be relied on by such infrastructure:
- Method: deliveries are HTTP
POSTonly. - Content-Type:
application/json. - Required headers:
X-MCP-Signature,X-MCP-Timestamp, andX-MCP-Subscription-Idare present on every delivery (see Replay attack prevention). A WAF MAY drop requests missing any of them as a cheap pre-filter; HMAC remains the actual security boundary. - Body size: servers SHOULD keep delivery bodies at or under 256 KiB, consistent with Payload Minimality. Receivers and intermediaries MAY reject larger bodies with
413 Payload Too Large; servers MUST treat413as a non-retryable failure for that event. - TLS: intermediaries MAY terminate TLS; nothing in the verification path depends on end-to-end TLS (the HMAC covers the raw body and timestamp).
- User-Agent: not specified by this document. A standard MCP user-agent format is under discussion in SEP-1336; if adopted, webhook deliveries would follow it. WAF rules SHOULD NOT rely on UA matching as a security control regardless.
- Source IP allowlisting: defence-in-depth, not the security boundary. A discovery mechanism for server egress ranges is deferred to the MCP Server Card work in SEP-2127; until then, servers SHOULD document their egress ranges out-of-band. Gateways that cannot obtain stable ranges (serverless, dynamic NAT) rely on HMAC alone, which is sufficient.
Unsubscribing: events/unsubscribe
events/unsubscribe is an eager cleanup mechanism. It is not required for correctness — subscriptions will expire naturally when the client stops refreshing. However, calling it immediately frees server resources and stops webhook deliveries without waiting for TTL expiry.
{
"jsonrpc": "2.0",
"method": "events/unsubscribe",
"id": 3,
"params": {
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"delivery": { "url": "https://proxy.example.com/hooks/client123" }
}
}
events/unsubscribe resolves the subscription using the same scoped key as events/subscribe. Both id and delivery.url are required (principal is supplied by the auth layer when present) so the server can form the full key.
events/unsubscribe is ONLY used for webhook delivery. Poll subscriptions are implicit (stop polling to unsubscribe). Push subscriptions are scoped to the events/stream connection (close to unsubscribe).
Listing Active Subscriptions
The client owns all subscription state across all three delivery modes. There is no server-side subscription listing method. The client maintains its own subscription registry and can reconstruct server-side state at any time via idempotent events/subscribe (for webhook mode) or by resuming poll/push with stored cursors.
Enterprise governance tools can inspect the client SDK's subscription registry for audit purposes. For webhook mode, orphaned subscriptions (e.g., from a crashed client) are cleaned up automatically by TTL expiry — no server-side listing is needed.
Cursor Lifecycle
Cursors are opaque strings managed by the server. They represent a position in the event stream.
Initial cursor: Passing cursor: null in a subscription (either in events/poll or events/stream) means "start from now." The server returns a fresh cursor representing the current position. No historical events are replayed.
Cursor expiry: Cursors may become invalid (upstream data compacted, server state reset). The server returns a CursorExpired error for the affected subscription. The client SDK SHOULD handle this by re-subscribing with cursor: null to get a fresh cursor.
For poll mode, this is a standard JSON-RPC error response to the events/poll request:
{"jsonrpc":"2.0","id":7,"error":{"code":-32014,"message":"CursorExpired","data":{"reason":"Upstream history compacted"}}}
For push mode, this appears as a notification on the stream:
data: {"jsonrpc":"2.0","method":"notifications/events/error","params":{"requestId":1,"id":"sub_email","error":{"code":-32014,"message":"CursorExpired","data":{"reason":"Upstream history compacted"}}}}
For webhook mode, the server POSTs an error envelope to the callback URL (signed like a normal delivery), and additionally surfaces it in deliveryStatus.lastError on the next refresh:
POST https://proxy.example.com/hooks/client123
X-MCP-Signature: sha256=<...>
X-MCP-Timestamp: 1739980800
{"id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","error":{"code":-32014,"message":"CursorExpired","data":{"reason":"Upstream history compacted"}}}
On CursorExpired, the client should reconnect the affected subscription with cursor: null.
Server SDK Guidance
The server SDK should make implementing events as simple as implementing tools. The server author writes one function — a "check for changes since cursor" function — and the SDK handles delivery mechanics for all three modes (poll, push, and webhook). The same check function backs every delivery mode; the SDK decides how to deliver based on what the client requested.
By default the server author does not declare which delivery modes an event type supports. The SDK infers them from the server's configuration and transport:
- Poll is always available — backed by the check function when one is provided, or by the SDK's emit-fed ring buffer otherwise (see Emit-only event types below). Poll support is mandatory at the protocol level, so the SDK MUST NOT offer an opt-out.
- Push is available when the transport supports streaming (Streamable HTTP or stdio).
- Webhook is available when the server has
webhook_ttlconfigured.
The SDK computes the delivery array in events/list responses from the above (always including poll), minus any push/webhook modes the author has explicitly disabled. Servers that call server.emit() for an event type additionally enable real-time push and webhook delivery without the SDK needing to run an internal polling loop.
@server.event(
name="email.received",
description="New email arrives",
input_schema={ ... },
payload_schema={ ... },
)
async def check_email(context, params, cursor):
"""Called by the SDK for all delivery modes."""
if cursor is None:
# Bootstrap: return current position, no events
current = await gmail.history().get_current_id()
return EventResult(events=[], cursor=current)
history = await gmail.history().list(startHistoryId=cursor)
events = []
for msg in history.messages:
if matches_params(params, msg):
data = {"messageId": msg.id, "from": msg.sender, ...}
if params.get("redact_pii"):
data = redact(data)
events.append(Event(name="email.received", eventId=msg.id, data=data))
return EventResult(events=events, cursor=history.historyId)
The SDK calls this function in different contexts depending on the delivery mode, but the server author does not need to distinguish between them:
For poll mode: The SDK calls the check function for the incoming events/poll request and returns the result. The server is stateless.
For push mode: The SDK supports two patterns:
- Poll-driven push (default). The SDK maintains an internal polling loop per push subscription, calling the check function periodically and writing events to the stream. This is the simplest path for servers wrapping poll-only APIs (e.g., Gmail's history endpoint).
- Direct emit. Servers with true push sources (webhooks, WebSocket listeners, change streams) can emit events directly via
server.emit(). The SDK routes emitted events to all active push streams with matching subscriptions.
Push state is scoped to the lifetime of the events/stream connection — when the connection closes, all loops and listeners stop.
For webhook mode: The SDK uses the same two patterns (poll-driven or direct emit), but instead of writing to an SSE stream, it POSTs events to the subscriber's callback URL with HMAC signatures. The SDK holds webhook subscriptions in memory with TTL — no external storage is required. If the server restarts, all webhook subscriptions are lost. Clients will re-subscribe on their next refresh cycle, passing their last-persisted cursor; for event types backed by a durable upstream this resumes without gaps, while emit-only event types lose events that occurred during the outage. This is by design: the mandatory TTL + refresh mechanism eliminates the need for durable subscription storage.
# Webhook mode uses in-memory subscription state with TTL.
# No external storage needed — clients refresh before expiry.
server = MCPServer(
webhook_ttl=timedelta(minutes=30), # subscriptions expire after 30 min
)
Direct emit works across all modes. When server.emit() is called, the SDK routes the event to all active subscriptions with matching event name. The SDK supports two emit patterns:
-
Broadcast emit. The server emits an event without specifying a subscription. The SDK fans out per active subscription for that event name using two author-supplied hooks:
match(ctx, event, params) -> booldecides whether the subscription receives the event. If absent, all subscriptions for that event name receive it.transform(ctx, event, params) -> eventshapes the payload for that subscription (e.g., applyredact_pii, expand or thin the payload per a subscriber'sexpandparam). If absent, the event is delivered as emitted.
ctxcarries the subscription's principal and request metadata. Because param semantics are author-defined (filters, globs, transforms — seeinputSchema), the SDK cannot evaluate them generically; these hooks let the author own both filtering and shaping. The SDK applies the same hooks when anevents/pollrequest reads from the emit-only ring buffer, so poll subscribers see the same filtering and shaping as push/webhook subscribers. This pattern fits upstream sources that deliver all events regardless of subscription params (e.g., a PagerDuty webhook that fires for every incident —matchfilters byseverity,transformredacts fields per subscriber). -
Targeted emit. The server emits an event to a specific subscription by ID. This is appropriate when the server has set up a per-subscription upstream listener and already knows which subscription the event belongs to.
@server.event(name="incident.created", ...)
class IncidentCreated:
@staticmethod
def match(ctx: Context, event: Event, params: dict) -> bool:
sev = params.get("severity")
return sev is None or event.data["severity"] == sev
@staticmethod
def transform(ctx: Context, event: Event, params: dict) -> Event:
if params.get("redact_pii"):
return event.replace(data={**event.data, "reporter": None})
return event
# Broadcast emit — SDK calls match then transform per active subscription.
async def on_pagerduty_webhook(payload):
server.emit(Event(
name="incident.created",
data={"incidentId": payload["id"], "severity": payload["severity"], ...}
))
# Targeted emit — server already knows the subscription.
async def on_slack_message(subscription_id, message):
server.emit(Event(
name="slack.message",
data={"text": message.text, "channel": message.channel, ...}
), subscription_id=subscription_id)
Emit-only event types. Many upstream sources are push-only with no cursor-addressable change feed — the server author receives upstream events (via webhook, WebSocket, message bus, etc.) and has no meaningful check() function to write. For these event types, the server author declares the event with emit_only=True and omits the check function entirely:
@server.event(
name="incident.created",
description="Fires when a new PagerDuty incident is created",
emit_only=True, # no check function; poll served from SDK ring buffer
input_schema={ ... },
payload_schema={ ... },
)
class IncidentCreated:
pass
async def on_pagerduty_webhook(payload):
server.emit(Event(name="incident.created", data={...}))
SDKs SHOULD provide an in-memory ring buffer that retains a bounded window of emitted events per event type (configurable by time and/or count). events/poll for an emit-only event type reads from this buffer. The cursor is a buffer-local sequence number; it is process-scoped — a server restart invalidates all cursors and clients receive CursorExpired, resetting to "now." Events emitted during server downtime are not recoverable. This matches the upstream's own guarantees for push-only sources and keeps the server author's implementation to a single emit() call per upstream event.
server = MCPServer(
emit_buffer=EmitBuffer(
max_age=timedelta(minutes=10), # retain 10 min of events
max_events=10_000, # cap per event type
),
)
For servers wrapping upstreams that do offer a durable cursor (Gmail historyId, Kubernetes resourceVersion, Stripe /v1/events, Kafka offset), authors SHOULD prefer a check() function that queries the upstream directly, so the MCP cursor is the upstream cursor and poll survives server restart without gaps.
Subscription lifecycle hooks. For events where the upstream source must be configured per subscription (e.g., "watch this Slack channel"), the server needs to know when subscriptions are added and removed. The SDK provides lifecycle hooks:
@server.on_subscribe("slack.message")
async def on_subscribe(context, params, subscription_id):
"""Set up upstream listener for this subscription's params."""
await slack.join_channel(params["channel"])
@server.on_unsubscribe("slack.message")
async def on_unsubscribe(context, params, subscription_id):
"""Tear down upstream listener."""
await slack.leave_channel(params["channel"])
These hooks are called by the SDK across all delivery modes. The server author writes the upstream setup/teardown logic once; the SDK handles the delivery mechanics. Servers SHOULD enforce TooManySubscriptions before invoking on_subscribe, so a rejected subscription never provisions upstream resources.
Unsubscribe timing by mode. Push and webhook have explicit end-of-life signals — push fires on_unsubscribe when the stream closes, webhook when events/unsubscribe is called or the TTL lapses. Poll does not: the client simply stops calling events/poll, and the server never sees a goodbye. To prevent poll-provisioned upstream resources from leaking, the SDK treats poll subscriptions as leased. The lease is keyed on (principal, eventName, canonicalHash(params)) — the request id is not used, since it is request-scoped and opaque. on_subscribe fires the first time a given key appears in a poll request; each subsequent poll for that key renews the lease; on_unsubscribe fires when the lease expires without renewal. This lease table is ephemeral SDK state, not protocol state: it is never persisted, and a server restart simply re-fires on_subscribe on the next poll. The lease window is SDK-configurable and SHOULD default to a small multiple of the server's typical nextPollSeconds so that a well-behaved client never lapses between polls:
server = MCPServer(
poll_subscription_ttl=timedelta(minutes=5),
)
This lease tracking is soft state the SDK maintains on the server author's behalf. It does not survive restart — on restart the next poll is "first sight" again and on_subscribe re-fires, which is the correct behavior since the upstream listener also needs re-establishing. Server authors SHOULD write on_subscribe to be idempotent for this reason.
Client SDK Guidance
Event names and params are not known at code time — they are discovered at runtime via events/list, typically driven by the LLM. The client SDK provides a dynamic subscription API:
client = MCPClient(
server_url="...",
webhook=WebhookConfig( # optional, global
url="https://proxy.example.com/hooks/client123",
# secret is server-generated by default; pass secret=... to override
),
)
# Subscribe dynamically (e.g., driven by LLM decision after events/list)
sub = await client.subscribe(
name="email.received",
params={"from": "*@anthropic.com", "redact_pii": True},
)
# Receive events
async for event in sub:
# Called regardless of delivery mode (poll, push, or webhook)
details = await client.call_tool("get_email", {"id": event.data["messageId"]})
# ... process the email
# Unsubscribe
await sub.cancel()
The SDK manages delivery mode selection, poll loops, push stream lifecycle, webhook refresh cycles, cursor tracking, deduplication by eventId, cursor expiry recovery, and reconnection — all transparently.
Delivery Mode Selection
The SDK intersects the event type's advertised delivery list with the modes the client is configured to use, and picks in this preference order:
- Webhook if the client has
WebhookConfigset and the server lists it — low latency without tying up a persistent connection. - Push if the transport supports streaming (stdio, or HTTP when webhook isn't configured) and the server lists it — low latency, but requires holding a connection open.
- Poll — always available; no server state, no persistent connection.
Because poll is mandatory on the server side, the intersection is never empty: every events-capable client can fall back to poll for any event type.
The caller can override per subscription:
# Force poll
sub = await client.subscribe("email.received", params={...}, delivery="poll")
# Force push (error if server doesn't support it)
sub = await client.subscribe("email.received", params={...}, delivery="push")
# Force webhook (error if WebhookConfig not set or server doesn't support it)
sub = await client.subscribe("incident.created", params={...}, delivery="webhook")
How Each Mode Works
Poll mode: The SDK runs a background loop per subscription that calls events/poll at the server-recommended interval (clamped to a configurable floor, default 1 s). Events are yielded to the subscription's async iterator. The caller does not implement or manage the polling loop.
Push mode: The SDK opens one events/stream per subscription and listens for notifications, routing them by requestId. Adding a subscription opens a new stream; removing one cancels its stream. On HTTP/1.1 each stream is a TCP connection, so SDKs SHOULD prefer HTTP/2 when many subscriptions are active.
Webhook mode: The SDK calls events/subscribe to register the callback URL with the server, which returns the signing secret. It runs a background refresh loop, re-calling events/subscribe before refreshBefore expires; if a refresh response includes a secret, the SDK updates the verifier. The SDK monitors deliveryStatus on each refresh to detect delivery failures. The webhook receiver (which may be a forward proxy) accepts incoming POSTs, verifies HMAC signatures, checks timestamp freshness, and routes events to the appropriate subscription iterator. On sub.cancel(), the SDK calls events/unsubscribe for eager cleanup.
Delivering Events to the LLM
When an event arrives (via any delivery mode), the client SDK surfaces it to the agent runtime. How this works depends on the agent framework — it might inject the event into the conversation context, trigger a new agent turn, or queue it for processing. The MCP spec does not prescribe this; it is an application-level concern.
Security Considerations
Event Payloads Are Untrusted Data
Event payloads MUST be treated with the same caution as tool results. They originate from external systems and may contain:
- Prompt injection attempts (e.g., an email subject line containing "ignore all previous instructions")
- Malformed or malicious data
- PII that should not be forwarded or logged
Clients SHOULD sanitize or sandbox event payloads before presenting them to an LLM. The spec should include guidance similar to the existing tool result security model.
Payload Minimality
Servers SHOULD keep event payloads minimal — enough to identify and triage the event, not the full content. For example, an email event should include sender, subject, and messageId, but not the full email body. The client can use tools to fetch full content when needed.
This reduces PII exposure in event infrastructure (logs, queues, buffers) and limits the injection surface area.
Authorization
Subscribe-time: The server MUST verify that the authenticated user has permission to subscribe to the requested event type with the given params. For example, a Slack server must verify the user has access to the channel specified in the params.
Delivery-time: The server SHOULD periodically re-verify permissions. If the user's access is revoked (e.g., removed from a Slack channel), the server terminates the subscription. The termination signal carries the same nested-error shape across all modes:
| Mode | Transport |
|---|---|
| Push | notifications/events/terminated on the SSE stream |
| Poll | JSON-RPC error response to the next events/poll request |
| Webhook | Signed error envelope POSTed to the callback URL, plus deliveryStatus.lastError on next refresh |
// Push
data: {"jsonrpc":"2.0","method":"notifications/events/terminated","params":{"requestId":1,"id":"sub_slack","error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}}}}
// Webhook (POST body)
{"id":"sub_slack","error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}}}
notifications/events/terminated has the shape {requestId: string|integer, id: string, error: {code: integer, message: string, data?: object}} — identical to notifications/events/error but indicates the subscription has ended, not just a per-event failure. The client SDK SHOULD remove the subscription and notify the application.
Action-time: Event receipt does NOT constitute authorization to act. The agent's response to an event (e.g., calling a tool) goes through normal MCP authorization. The spec should be explicit about this.
Enterprise Governance
The spec does not mandate specific governance mechanisms but is designed to enable them:
- Audit: The client owns all subscription state. Enterprise agent runtimes can inspect the client SDK's subscription registry and log all event-triggered actions.
- Policy: Clients SHOULD support policy evaluation between event receipt and action execution. The policy language is an application concern, not a protocol concern.
- Kill switch: For poll mode, stop polling. For push mode, abort the request stream (HTTP) or send
notifications/cancelled(stdio). For webhook mode, callevents/unsubscribefor immediate termination, or simply stop refreshing and the subscription will expire at TTL. - Rate limiting:
nextPollSecondsallows servers to control polling frequency dynamically. Clients SHOULD respect it. Enterprise deployments may impose additional rate limits.
Relationship to Existing Primitives
Resources: notifications/resources/updated remains unchanged. Events are a separate primitive for domain-specific occurrences that don't map to a specific resource. Servers MAY implement resource watching via events, but the two mechanisms are independent.
Tools: Events and tools compose at the application layer. An event arrives, the agent reasons about it, and may call tools in response. The MCP spec does not prescribe event-to-tool wiring.
Prompts: Future work may add event-bound prompts (prompt templates designed to be instantiated when a specific event fires). Not in scope for v1.
Sampling: Events may trigger LLM reasoning. The client decides whether to invoke sampling in response to an event — the server does not control this.
What Is NOT in v1
- Durable subscriptions with replay. No mechanism to replay events from before subscription time. Cursors represent "now" forward.
- Message queue bindings. The protocol does not define bindings for specific message queue systems (SQS, MQTT, Kafka, Pub/Sub, etc.). For high-availability delivery through message queues, deploy an MCP proxy that receives events via poll, push, or webhook and writes to your organization's queue infrastructure. The client reads from the queue using the queue's native client. This keeps the protocol transport-agnostic while enabling any intermediary.
- Rich query language for params. Params are simple key-value. No CEL, JSONPath, or complex predicates. Servers define their own param semantics via
inputSchema. - Cross-server event routing. No mechanism for one server's events to trigger another server's tools. This is an application/orchestration concern.
- Event-bound prompts. Prompt templates that auto-instantiate on events. Deferred to a future version.
- Guaranteed delivery. All three modes provide at-least-once delivery when the cursor is backed by a durable upstream and the client replays from its last known cursor on reconnect/restart. Emit-only event types are at-most-once across server restarts — the in-memory buffer and its cursors do not survive (see Emit-only event types). Exactly-once requires application-level deduplication via
eventId.
Open Questions
-
How should the client SDK expose events to agent frameworks? As injected context? As a special message type? As a tool-call-like callback? This is an SDK design question, not a protocol question, but guidance would be useful.
-
Should resource subscriptions and
list_changednotifications be re-expressed as events? The existingresources/subscribe→notifications/resources/updatedflow and thenotifications/{tools,resources,prompts}/list_changedfamily are effectively a special-cased push-only event channel. Folding them into this primitive (e.g., a reservedmcp.resource.updatedevent type whose params are{uri}, andmcp.{tools,resources,prompts}.list_changedevent types) would give them cursors, poll/webhook delivery, and replay for free, and remove a parallel mechanism from the spec. The cost is a migration path for existing clients and the question of whether the protocol should reserve anmcp.*event-name prefix at all. -
How should servers publish their egress IP ranges for webhook delivery? Enterprises deploying webhook mode behind a WAF will typically want to allowlist source IPs as defence-in-depth (HMAC remains the security boundary). Rather than defining a bespoke mechanism here, this should ride on the MCP Server Card discovery work in SEP-2127 — egress ranges become a field on the server card. Servers using dynamic egress (cloud NAT, serverless) may omit it. See Delivery profile under Webhook Security for the broader WAF-configuration story.
-
Should webhook delivery adopt the Standard Webhooks format instead of defining its own? Standard Webhooks specifies a delivery envelope very close to what this doc defines:
webhook-id,webhook-timestamp, andwebhook-signatureheaders,HMAC-SHA256(secret, id + "." + timestamp + "." + body)with awhsec_prefix on secrets, base64v1,<sig>signature encoding with multi-signature support for rotation, and a versioned scheme that already accommodates asymmetric signing (v1a,). Adopting it would let MCP receivers reuse off-the-shelf verifiers (Svix and others ship libraries in most languages) and would resolve open question 6 below for free. The cost is losing theX-MCP-*header namespace and binding the spec to an external document we don't control. A middle path is to declare MCP webhook delivery a Standard Webhooks profile — same wire format, with MCP-specific body schema and the subscriptionidcarried aswebhook-id. -
Can a single subscription span multiple event names with one cursor? (wire-breaking if adopted) Several upstreams expose one ordered change feed that yields multiple event types — a Kubernetes watch on a namespace produces pod, deployment, and event objects; a Kafka consumer on one topic yields heterogeneous message kinds; a Slack Socket Mode connection delivers messages and reactions. Under the current model, a client wanting both
k8s.pod_phase_changedandk8s.oom_killedopens two subscriptions with two cursors, and the server runs two parallel watches against the same apiserver. Options: (a) keep per-event-name subscriptions and let the server SDK internally coalesce upstream connections (no protocol change, SDK complexity); (b) allow a subscription'snameto be an array so one cursor covers a set of event types (protocol change, simpler server, client must demux); (c) introduce an event-group concept at registration time. The TypeScript SDK stress-test hit this with both Slack and Kubernetes. -
Should webhook deliveries support multiple signatures for zero-downtime secret rotation? (wire-breaking if adopted) The current design has a single
X-MCP-Signatureheader, and rotation happens via an atomic upsert ofdelivery.secret. But there's a race: webhooks in flight when the upsert lands were signed with the old secret, and a receiver that has already switched to validating against the new secret will reject them. Stripe-style multi-signature (e.g.,X-MCP-Signature: t=<ts>,v1=<sig_old>,v1=<sig_new>) lets the server sign with both secrets during a grace window so the receiver can verify against either. Is the in-flight window small enough to ignore, or should the spec allowdelivery.secretto be an array (or require servers to dual-sign for N seconds after rotation)?
Note on Consistency and Ordering
This design intentionally does not provide protocol-level guarantees around event ordering, exactly-once delivery, or transactional consistency. There are no logical clocks, sequence numbers, or cross-subscription ordering constraints.
The rationale is that the protocol connects to many different upstream systems (Gmail, Slack, PagerDuty, etc.), each with their own consistency models. Imposing a unified consistency model at the MCP layer would either be too weak to be useful or too strong to be implementable across diverse backends.
What the protocol does provide:
- Cursors for resumability — the client can pick up where it left off after a disconnect or crash.
eventIdfor client-side deduplication — the client can detect and discard duplicates that arise during reconnection. Servers SHOULD set this to the upstream's stable event identifier so dual-path delivery (emit + poll) of the same upstream event collapses to one.- Per-subscription ordering — for poll and push, events within a single subscription are delivered in the order the server produces them. For webhook, delivery order is best-effort: each event is retried independently, so concurrent requests and retries can reorder arrivals (this matches Stripe, GitHub, Shopify, and the Standard Webhooks spec); the safe-watermark cursor in each payload makes this safe to persist regardless of arrival order. Clients that need ordering use the
timestampfield in event payloads. No ordering is guaranteed across subscriptions in any mode.
Servers that need stronger guarantees can implement them. For example, a server wrapping a Kafka topic could expose partition offsets as cursors and provide exactly-once semantics within a partition. A database change-data-capture server could use LSNs as cursors and guarantee causal ordering. The protocol's cursor mechanism is flexible enough to support these — the cursor is opaque, so it can encode whatever the server needs (sequence numbers, timestamps, composite positions).
The client and agent layer should be designed to tolerate out-of-order and duplicate events. For most AI agent use cases — reacting to new emails, triaging alerts, responding to messages — eventual consistency with deduplication is sufficient. Agents that need stronger guarantees should use tools to read authoritative state rather than relying solely on event payloads.
Note on Flow Control
This design does not include a protocol-level flow control mechanism (e.g., credit-based or token-based backpressure). This is intentional for v1.
In practice, the dominant bottleneck in an MCP event pipeline is LLM inference — processing a single event may take seconds to minutes of model time. Compared to this, event delivery rates from typical upstream sources (email, Slack, PagerDuty) are negligible. The system is inherently consumer-bound, not producer-bound.
What exists today is sufficient:
- Transport-level backpressure. TCP flow control naturally throttles the server when the client stops reading from the connection. On stdio, OS pipe buffers provide the same effect.
- Server-controlled push frequency. For push mode, the server controls how often it checks upstream sources and delivers events. For poll mode,
nextPollSecondslets the server throttle the client explicitly. - Client-side prioritization. When multiple events arrive while the LLM is busy, the client SDK can prioritize by event type, severity, or recency. This is an SDK concern, not a protocol concern.
Head-of-line blocking — where a slow-to-process event delays delivery of subsequent events — is likewise handled at the client SDK layer. The SDK can maintain per-subscription queues and let the agent framework process events concurrently or by priority, rather than strictly in arrival order.
One known gap is push-mode reconnect replay: a client reconnecting with a stale cursor may receive a large backlog burst on the stream, with no protocol-level bound equivalent to poll's maxEvents/hasMore. For v1 this is left to TCP backpressure and server-side pacing. If future use cases involve high-throughput event streams where producer-side backpressure or bounded replay becomes necessary, a credit-based flow control extension can be added without breaking the existing protocol.
Appendix: End-to-End Example — GitHub MCP Server (Webhook Delivery)
This walks one concrete topology from upstream source to agent reaction, using webhook delivery via a forward proxy. The MCP server wraps the GitHub API: it receives PR activity from GitHub via GitHub's native repository webhooks (the upstream channel — outside this protocol), and delivers it to a forward proxy via MCP webhook (events/subscribe). The proxy buffers events and serves them to the client over poll or push. When an event arrives, the client SDK wakes the LLM agent.
sequenceDiagram
autonumber
participant LLM as LLM Agent
participant SDK as Client SDK
participant Proxy as Forward Proxy<br/>(webhook endpoint)
participant Server as GitHub MCP Server
participant Redis as Redis<br/>(sub store, TTL)
participant GH as GitHub.com<br/>(upstream)
rect rgb(245,245,250)
Note over SDK,Redis: Subscribe (webhook delivery)
SDK->>Server: events/subscribe<br/>{id, name: "pull_request.opened", params: {repo: "acme/webapp"},<br/>delivery: {mode: "webhook", url: "https://proxy/.../hooks"}}
Server->>Redis: SETEX sub:{principal}:{urlHash}:{id} {ttl}<br/>{name, params, url, secret}
Server->>GH: ensure repo webhook registered<br/>(server-internal, idempotent)
Server-->>SDK: {secret, refreshBefore}
end
rect rgb(245,250,245)
Note over GH,LLM: Event fires
GH->>Server: POST /gh-webhook<br/>{action: "opened", pull_request: {...}}
Server->>Redis: SCAN sub:* → match repo
Redis-->>Server: [{id, url, secret}]
Server->>Proxy: POST {id, event: {number: 42, title, author, url}, cursor'}<br/>+ HMAC signature
Proxy-->>Server: 200 OK
Proxy--)SDK: deliver event<br/>(implementation-defined, e.g. poll/push)
SDK->>LLM: invoke agent with event payload
end
rect rgb(250,245,245)
Note over SDK,Redis: Keepalive
SDK->>Server: events/subscribe (same key — refresh TTL)
Server->>Redis: EXPIRE sub:{principal}:{urlHash}:{id} {ttl}
Server-->>SDK: {refreshBefore'}
end
Note over Redis: no refresh → key expires,<br/>subscription gone
The Redis lane is illustrative, not normative. The body of this doc says webhook subscriptions are "in memory with TTL"; that is sufficient for a single-process server. A horizontally scaled server (multiple replicas behind a load balancer) needs shared state so any replica can match an incoming upstream event to subscriptions created on another replica — Redis with key TTL is the obvious fit, but any shared store with expiry works. The protocol does not require this state to be durable: if it is lost, clients re-create it on their next refresh and the client's persisted cursor covers the gap (for event types backed by a durable upstream).
Note there are two webhook hops here, and they are unrelated: GitHub → MCP Server (step 4) is GitHub's native webhook product, configured server-side and outside this spec; MCP Server → Forward Proxy (step 5) is the MCP events/subscribe webhook defined above.
Substituting other delivery modes. Only the Subscribe and Event fires boxes change; the upstream leg (GitHub → MCP Server) is identical in all three modes.
- Push — step 1 becomes
events/stream; drop the Forward Proxy lane and the Keepalive box; steps 5–7 collapse into a singlenotifications/events/eventfrom server to SDK (see Push-Based Delivery). - Poll — drop step 1 and the Forward Proxy lane; the SDK loops
events/pollwith the subscription inline, and step 5–7 become the poll response (see Poll-Based Delivery).