From 603f5de12a11944b6524fa2d2290415a311bde1a Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Thu, 9 Apr 2026 21:30:11 +0100 Subject: [PATCH 01/25] Add Events design sketch proposal --- docs/design-sketch-proposal.md | 822 +++++++++++++++++++++++++++++++++ 1 file changed, 822 insertions(+) create mode 100644 docs/design-sketch-proposal.md diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md new file mode 100644 index 0000000..c60fe37 --- /dev/null +++ b/docs/design-sketch-proposal.md @@ -0,0 +1,822 @@ +# 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: + +- **Servers advertise delivery modes; no mode is mandatory.** Each event type lists the delivery modes it supports. The client picks the best mode it can use. If there is no overlap between what the server offers and what the client can consume, that event type is simply unavailable to that client — the protocol does not require a universal fallback. (Reference SDKs are still encouraged to make poll cheap to support so that overlap is common in practice.) +- **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 subscription state at all. 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 *subscription* state. Servers that relay from upstream sources will still hold other state — upstream credentials, webhook registrations with the upstream, an in-memory event buffer — but none of it is owed to any particular MCP client and all of it is 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 has no subscription state at all. 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: + +```jsonc +{ + "capabilities": { + "events": { + "subscribe": true + } + } +} +``` + +## Listing Available Events + +### Request: `events/list` + +```jsonc +// params (optional) +{ "cursor": "..." } // pagination +``` + +### Response + +```jsonc +{ + "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:** + +- `delivery` lists the delivery modes this event type supports — any non-empty subset of `"poll"`, `"push"`, `"webhook"`. No mode is mandatory. A client that cannot use any of the listed modes cannot subscribe to this event type. +- `inputSchema` is 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 the `inputSchema` on tools for consistency. +- `payloadSchema` describes the shape of `data` in 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/poll` with event name, params, and cursor. No separate subscribe step needed — the first poll with a null cursor bootstraps the subscription. Server is fully stateless. +- **Push mode:** Client opens a long-lived POST (`events/stream`) carrying all desired subscriptions. Events are delivered on the SSE response stream (HTTP) or as notifications on stdout (stdio). Connection close terminates all subscriptions. Server state is scoped to connection lifetime. +- **Webhook mode:** Client calls `events/subscribe` to 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-calling `events/subscribe` before 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 (applicable to all modes) + +| Code | Message | Meaning | +|------|---------|---------| +| `-32602` | `InvalidParams` | Params don't match inputSchema (standard JSON-RPC invalid params) | +| `-32001` | `EventNotFound` | Unknown event name | +| `-32002` | `Unauthorized` | User lacks permission for this event/params combination | +| `-32003` | `TooManySubscriptions` | Server-imposed subscription limit reached | +| `-32005` | `InvalidCallbackUrl` | Webhook URL is unreachable or rejected by the server | +| `-32006` | `SubscriptionNotFound` | Unknown subscription ID (for `events/unsubscribe`) | + +### 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. + +#### Request: `events/poll` + +```jsonc +{ + "maxEvents": 50, // optional; cap events per subscription + "subscriptions": [ + { + "id": "sub_email", // client-provided identifier + "name": "email.received", + "params": { + "from": "*@anthropic.com", + "redact_pii": true + }, + "cursor": null // null = start from now + }, + { + "id": "sub_incidents", + "name": "incident.created", + "params": { "severity": "P1" }, + "cursor": "cursor_abc" // resume from previous position + } + ] +} +``` + +#### Response + +```jsonc +{ + "results": [ + { + "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 + }, + { + "id": "sub_incidents", + "events": [], + "cursor": "cursor_abc", + "hasMore": false, + "nextPollSeconds": 60 + } + ] +} +``` + +**Notes:** + +- `id` is a client-provided identifier for each subscription. It is opaque to the server and echoed back in responses to allow the client to correlate results with subscriptions. It must be unique within a single `events/poll` request. +- `cursor` is opaque to the client. The client stores it and passes it back on the next poll. A `null` cursor means "start from now" — the server returns no events and provides a fresh cursor for subsequent polls. +- `eventId` enables client-side deduplication across polls (e.g., after a crash/restart). +- `maxEvents` is an optional top-level field that caps the number of events returned per subscription. If more events are available than the limit, the server returns a partial batch with an intermediate cursor and sets `hasMore: true`. The client SHOULD poll again immediately (ignoring `nextPollSeconds`) to drain the backlog. If omitted, the server uses its own default limit. +- `hasMore` indicates whether additional events are available beyond the returned batch. When `true`, the client should poll again immediately with the updated cursor. When `false`, the client waits `nextPollSeconds` before the next poll. +- `nextPollSeconds` allows the server to dynamically adjust polling frequency per subscription (e.g., back off when rate-limited upstream, speed up when activity is detected). Ignored when `hasMore` is `true`. +- Empty `events` array means nothing happened — this is the common case and should be cheap. +- The server holds no 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. (For emit-only event types, the server does hold a ring buffer of recent events — see *Emit-only event types* under Server SDK Guidance — but that buffer is shared infrastructure, not per-subscription state.) + +#### Error Handling + +If an individual subscription within a poll request is invalid, the server returns an error for that subscription without failing the entire request: + +```jsonc +{ + "results": [ + { + "id": "sub_email", + "events": [ "..." ], + "cursor": "historyId_99842", + "nextPollSeconds": 30 + }, + { + "id": "sub_bogus", + "error": { + "code": -32001, + "message": "EventNotFound" + } + } + ] +} +``` + +### Push-Based Delivery + +Push delivery uses a long-lived `events/stream` request. The client sends all desired subscriptions, 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`. + +The transport mechanism differs by transport type: + +- **Streamable HTTP:** The `events/stream` request is a POST that returns an SSE response stream. This replaces the GET-based SSE stream — `events/stream` is the sole server→client push channel. Connection close implicitly cancels — no explicit cancellation message is needed. +- **stdio:** The `events/stream` request 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 sending `notifications/cancelled` with the request's `id`. + +#### Request: `events/stream` + +```jsonc +// Streamable HTTP: POST /mcp +// stdio: written to stdin +{ + "jsonrpc": "2.0", + "method": "events/stream", + "id": 1, + "params": { + "subscriptions": [ + { + "id": "sub_email", + "name": "email.received", + "params": { "from": "*@anthropic.com", "redact_pii": true }, + "cursor": null + }, + { + "id": "sub_incidents", + "name": "incident.created", + "params": { "severity": "P1" }, + "cursor": "cursor_abc" + } + ] + } +} +``` + +#### Event Delivery + +The server confirms each subscription, reports errors for invalid ones, and then delivers events as notifications. When the stream ends, the server sends a `StreamEventsResult` as the final frame: + +```jsonc +// Confirmation (one per valid subscription) +{"jsonrpc":"2.0","method":"notifications/events/active","params":{"id":"sub_email","cursor":"historyId_99840"}} +{"jsonrpc":"2.0","method":"notifications/events/active","params":{"id":"sub_incidents","cursor":"cursor_abc"}} + +// Error for invalid subscription (stream remains open for valid ones) +{"jsonrpc":"2.0","method":"notifications/events/error","params":{"id":"sub_bogus","code":-32001,"message":"EventNotFound"}} + +// Events as they occur +{"jsonrpc":"2.0","method":"notifications/event","params":{"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"}} + +// Final frame when stream closes (StreamEventsResult) +{"jsonrpc":"2.0","id":1,"result":{"_meta":{}}} +``` + +On Streamable HTTP, notifications are SSE `data:` frames, and the `StreamEventsResult` is the final `data:` frame that closes the request. On stdio, notifications are newline-delimited JSON messages on stdout, and the result is sent when the stream is cancelled. + +Non-event MCP notifications (e.g., `notifications/tools/list_changed`, `notifications/resources/updated`) also flow alongside event notifications. + +#### Lifecycle + +- **Stream termination.** The `StreamEventsResult` is an empty typed result (`{"_meta": {}}`) sent as the final message when the stream closes. It carries no information — it satisfies JSON-RPC's requirement that every request gets a response. All meaningful content is in the preceding notifications. +- **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 a `notifications/events/heartbeat` message. The server SHOULD send a heartbeat at least every 30 seconds. The client SHOULD treat absence of any data (events or heartbeats) beyond a threshold (e.g., 60 seconds) as connection failure and reconnect with cursors. +- **Cancellation.** On Streamable HTTP, the client closes the connection. On stdio, the client sends `notifications/cancelled` with the `requestId` matching the `events/stream` request's `id`. In both cases, the server MUST stop delivering events, send the `StreamEventsResult`, and release any associated resources. +- **Updating subscriptions.** To add, remove, or change subscriptions, the client cancels the current stream (connection close on HTTP, `notifications/cancelled` on stdio) and sends a new `events/stream` request with the updated subscription list and current cursors. The cursor-based replay ensures no events are missed during the brief transition. +- **Reconnection after failure.** If the connection drops (HTTP) or the server stops sending (stdio), the client sends a new `events/stream` with the same subscriptions and their last-known cursors. +- **Empty subscriptions.** A client may open `events/stream` with an empty `subscriptions` array solely to receive non-event notifications (e.g., `notifications/tools/list_changed`). + +#### 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) +``` + +#### 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. + +```jsonc +{ + "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 + } +} +``` + +```jsonc +// Response +{ + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "secret": "whsec_5c8f...", // present when subscription is (re)created + "cursor": "cursor_start_001", + "refreshBefore": "2026-02-19T16:30:00Z" +} +``` + +**Notes:** + +- `events/subscribe` is ONLY used for webhook delivery. Poll and push do not need it. +- `id` is 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. +- `secret` is 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 fresh `secret` appears in the response — the client MUST check for its presence on every refresh and update the verifier accordingly. The client MAY supply `delivery.secret` in 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` follows the same semantics as other modes: `null` means "start from now" on a new subscription. When refreshing an existing subscription, `null` means "keep the server's current position"; a non-null value resets the subscription's replay position. +- `refreshBefore` is mandatory. It is an ISO 8601 timestamp indicating when the subscription will expire. The client MUST re-call `events/subscribe` with the same subscription key before this time to keep the subscription alive. The server resets the TTL on each refresh. +- `events/subscribe` is 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, cursor) in memory with TTL. No durable storage is required — if the server restarts, clients will re-subscribe on their next refresh cycle, and cursors ensure no events are missed. + +#### 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, 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. Servers that support both authenticated and anonymous access MUST select the scoping mode per-request: `(principal, id)` whenever a principal is present, `(delivery.url, id)` otherwise. 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. key fields.** On an idempotent subscribe against an existing key, the server updates fields as follows: + +| Field | Behavior on existing subscription | +|---|---| +| `name`, `params` | Replaced. Changing these redefines what the subscription listens for. | +| `delivery.url` | **Authenticated scope:** replaced — the key is `(principal, id)`, so the URL is mutable. **Unauthenticated scope:** part of the key — a different URL addresses a different subscription. | +| `delivery.secret` | Replaced if supplied (client-driven rotation / override). If omitted, the existing secret is unchanged and is not returned. | +| `cursor` | Replaced if non-null; ignored if null (the server keeps its current position — a refresh loop typically passes `null`). | +| TTL | Reset. | + +**Cross-tenant isolation.** Because the subscription key always includes either `principal` or `delivery.url`, 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= +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-Signature` contains an HMAC-SHA256 signature computed over `timestamp + "." + body` using the shared secret from subscription. The receiver MUST verify this before processing. The `X-MCP-Timestamp` header contains the Unix timestamp (seconds) of the request; the receiver SHOULD reject deliveries older than 5 minutes to prevent replay attacks. +- `eventId` in the body enables idempotent processing. The receiver SHOULD deduplicate by this value. +- The server SHOULD retry on non-2xx responses with exponential backoff. +- **Subscribe/delivery race.** Because the server may begin delivering as soon as the subscription is persisted, the first webhook POST can arrive before the `events/subscribe` response (and thus the `secret`) reaches the client. The recommended handling for now is retry tolerance: a receiver that gets a delivery for an `id` it does not yet recognise, or that it cannot yet verify, SHOULD return a retryable status (e.g., `503` or `425 Too Early`) rather than dropping it; the server's normal retry/backoff machinery will redeliver once the client is ready. `eventId` deduplication and cursor replay make this safe. A two-phase activate handshake was considered and may be added later if this proves insufficient in practice. +- After repeated failures (server-defined threshold), the server MAY stop retrying for the remainder of the TTL. The subscription will expire naturally, and the client's next refresh re-establishes it with cursor-based replay. + +#### 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: + +```jsonc +// Healthy subscription refresh +{ + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "cursor": "cursor_xyz", + "refreshBefore": "2026-02-19T17:00:00Z", + "deliveryStatus": { + "active": true, + "lastDeliveryAt": "2026-02-19T16:28:00Z", + "lastError": null + } +} +``` + +```jsonc +// Subscription with delivery failures +{ + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "cursor": "cursor_xyz", + "refreshBefore": "2026-02-19T17:00:00Z", + "deliveryStatus": { + "active": false, + "lastDeliveryAt": "2026-02-19T15:45:00Z", + "lastError": "Webhook endpoint returned 403 Forbidden", + "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 stopped retrying after repeated failures). `lastError` provides a human-readable description of the most recent failure. 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 at subscribe time. Servers SHOULD reject URLs pointing to private or loopback address ranges (`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`) unless explicitly configured to allow them. The server SHOULD resolve the hostname and validate the resolved IP before accepting the subscription to prevent DNS rebinding attacks. Servers MAY maintain an allowlist of permitted callback URL patterns. + +**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 SHOULD 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: `HMAC-SHA256(secret, timestamp + "." + body)`. The receiver SHOULD reject deliveries where the timestamp is more than 5 minutes old. This prevents captured webhook payloads from being replayed. + +**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. + +#### 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. + +```jsonc +{ + "jsonrpc": "2.0", + "method": "events/unsubscribe", + "id": 3, + "params": { + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" + } +} +``` + +`events/unsubscribe` resolves the subscription using the same scoped key as `events/subscribe`. For authenticated callers, `(principal, id)` is sufficient and the payload above is complete. For unauthenticated callers, the client MUST also include `delivery.url` so the server can form the `(delivery.url, id)` key: + +```jsonc +// Unauthenticated server +{ + "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` 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 appears in the per-subscription results: + +```jsonc +{ + "id": "sub_email", + "error": { + "code": -32004, + "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":{"id":"sub_email","code":-32004,"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 available by default — 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). Authors MAY opt an event type out of poll (e.g., when buffering is undesirable for a high-volume push-only source). +- **Push** is available when the transport supports streaming (Streamable HTTP or stdio). +- **Webhook** is available when the server has `webhook_ttl` configured. + +The SDK computes the `delivery` array in `events/list` responses from the above, minus any modes the author has explicitly disabled. The protocol does not require any particular mode to be present, so the SDK MUST also accept an explicit `delivery=[...]` override per event type for authors who want full control. 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. + +```python +@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", 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 each subscription in an incoming `events/poll` request and returns the results. 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-known cursor to resume without missing events. This is by design: the mandatory TTL + refresh mechanism eliminates the need for durable subscription storage. + +```python +# 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 matches the event against all active subscriptions' params and delivers to those that match. This is appropriate when the upstream source delivers all events regardless of subscription params (e.g., a PagerDuty webhook that fires for all incidents — the SDK filters by `severity`). +- **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. + +```python +# Broadcast emit — SDK filters by subscription params. +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: + +```python +@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. + +```python +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: + +```python +@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. + +**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. `on_subscribe` fires the first time a given `(principal, subscription id)` appears in a poll request; each subsequent poll for that id renews the lease; `on_unsubscribe` fires when the lease expires without renewal. 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: + +```python +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: + +```python +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: + +1. **Webhook** if the client has `WebhookConfig` set and the server lists it — low latency without tying up a persistent connection. +2. **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. +3. **Poll** if the server lists it — no server state, no persistent connection. + +If the intersection is empty, `client.subscribe()` raises `NoCompatibleDeliveryMode`. The protocol does not guarantee a universal fallback, so client SDKs MUST surface this as an error rather than silently degrading. + +The caller can override per subscription: + +```python +# 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 task that calls `events/poll` at the server-recommended interval, batching all subscriptions to the same server into a single request. Events are yielded to the subscription's async iterator. The caller does not implement or manage the polling loop. + +**Push mode:** The SDK sends an `events/stream` request and listens for notifications. When subscriptions are added or removed, the SDK cancels the current stream (via `notifications/cancelled` or connection close) and sends a new `events/stream` with the updated subscription list and current cursors. The transition gap is covered by cursor-based replay. + +**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 notifies the client. For push mode, this is sent on the SSE stream: + +``` +data: {"jsonrpc":"2.0","method":"notifications/events/terminated","params":{"id":"sub_slack","reason":"Access revoked"}} +``` + +For poll mode, this appears as an error in the poll response for that subscription. 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, close the connection (HTTP) or send `notifications/cancelled` (stdio). For webhook mode, call `events/unsubscribe` for immediate termination, or simply stop refreshing and the subscription will expire at TTL. +- **Rate limiting:** `nextPollSeconds` allows 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 cursors are used correctly (the client replays from the last known cursor on reconnect/restart). Exactly-once requires application-level deduplication via `eventId`. + +## Open Questions + +1. **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. + +2. **Should resource subscriptions and `list_changed` notifications be re-expressed as events?** The existing `resources/subscribe` → `notifications/resources/updated` flow and the `notifications/{tools,resources,prompts}/list_changed` family are effectively a special-cased push-only event channel. Folding them into this primitive (e.g., a reserved `mcp.resource.updated` event type whose params are `{uri}`, and `mcp.{tools,resources,prompts}.list_changed` event 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 an `mcp.*` event-name prefix at all. + +3. **How should servers publish their egress IP ranges for webhook delivery?** Enterprises deploying webhook mode will typically need to allowlist the source IPs that webhook requests originate from. Should the spec define a discovery mechanism (e.g., a well-known endpoint like `/.well-known/mcp-egress-ips`, or a field in the server's capabilities/metadata)? Or leave it to out-of-band documentation per server? A standard mechanism would simplify enterprise onboarding, but adds burden on server operators who may use dynamic egress (cloud NAT, serverless) where stable IP ranges are hard to guarantee. + +4. **Should webhook delivery adopt the Standard Webhooks format instead of defining its own?** [Standard Webhooks](https://www.standardwebhooks.com/) specifies a delivery envelope very close to what this doc defines: `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers, `HMAC-SHA256(secret, id + "." + timestamp + "." + body)` with a `whsec_` prefix on secrets, base64 `v1,` 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 5 below for free. The cost is losing the `X-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 subscription `id` carried as `webhook-id`. + +5. **Should webhook deliveries support multiple signatures for zero-downtime secret rotation?** The current design has a single `X-MCP-Signature` header, and rotation happens via an atomic upsert of `delivery.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=,v1=,v1=`) 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 allow `delivery.secret` to 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. +- **`eventId`** for client-side deduplication — the client can detect and discard duplicates that arise during reconnection. +- **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 — concurrent HTTP requests may arrive out of order, and retries can reorder deliveries. Clients that need ordering can use the `timestamp` field 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, `nextPollSeconds` lets 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. + +If future use cases involve high-throughput event streams where producer-side backpressure becomes necessary, a credit-based flow control extension can be added without breaking the existing protocol. From 4f8a1129d8646d6f5aa6fb1968fa8083185ceda9 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Fri, 10 Apr 2026 12:41:21 +0100 Subject: [PATCH 02/25] Add delivery-mode diagrams, e2e webhook appendix, and clarifications - Mermaid sequence diagrams for poll/push/webhook delivery - Appendix: end-to-end GitHub example with Redis sub store - Push: clarify multiple concurrent streams allowed; note HTTP/2 dependency - Webhook: specify 2xx ACK requires durability or end-consumer processing - Use nextPollSeconds consistently --- docs/design-sketch-proposal.md | 110 ++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 2 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index c60fe37..7623bb6 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -119,6 +119,19 @@ There are three delivery modes with different subscription mechanisms: The client SDK calls `events/poll` at the server-recommended interval. This is a protocol-level operation, NOT an LLM tool call. +```mermaid +sequenceDiagram + autonumber + participant SDK as Client SDK + participant Server as MCP Server + + loop every nextPollSeconds + SDK->>Server: events/poll {subscriptions[], cursors} + Server-->>SDK: {results: [{events[], cursor}], nextPollSeconds} + end + Note over SDK: LLM invoked only when
events[] is non-empty +``` + #### Request: `events/poll` ```jsonc @@ -223,6 +236,23 @@ The transport mechanism differs by transport type: - **Streamable HTTP:** The `events/stream` request is a POST that returns an SSE response stream. This replaces the GET-based SSE stream — `events/stream` is the sole server→client push channel. Connection close implicitly cancels — no explicit cancellation message is needed. - **stdio:** The `events/stream` request 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 sending `notifications/cancelled` with the request's `id`. +```mermaid +sequenceDiagram + autonumber + participant SDK as Client SDK + participant Server as MCP Server + + SDK->>Server: events/stream {id, subscriptions[]} + activate Server + Server-->>SDK: notifications/events/active {id, cursor} ×N + loop as events occur + Server-->>SDK: notifications/events/event {id, event, cursor} + end + SDK->>Server: notifications/cancelled {requestId} (stdio)
— or connection close (Streamable HTTP) + Server-->>SDK: StreamEventsResult (final frame) + deactivate Server +``` + #### Request: `events/stream` ```jsonc @@ -279,7 +309,7 @@ Non-event MCP notifications (e.g., `notifications/tools/list_changed`, `notifica - **Stream termination.** The `StreamEventsResult` is an empty typed result (`{"_meta": {}}`) sent as the final message when the stream closes. It carries no information — it satisfies JSON-RPC's requirement that every request gets a response. All meaningful content is in the preceding notifications. - **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 a `notifications/events/heartbeat` message. The server SHOULD send a heartbeat at least every 30 seconds. The client SHOULD treat absence of any data (events or heartbeats) beyond a threshold (e.g., 60 seconds) as connection failure and reconnect with cursors. - **Cancellation.** On Streamable HTTP, the client closes the connection. On stdio, the client sends `notifications/cancelled` with the `requestId` matching the `events/stream` request's `id`. In both cases, the server MUST stop delivering events, send the `StreamEventsResult`, and release any associated resources. -- **Updating subscriptions.** To add, remove, or change subscriptions, the client cancels the current stream (connection close on HTTP, `notifications/cancelled` on stdio) and sends a new `events/stream` request with the updated subscription list and current cursors. The cursor-based replay ensures no events are missed during the brief transition. +- **Updating subscriptions.** A client MAY hold multiple concurrent `events/stream` requests open, each with its own subscription list. To add subscriptions, open an additional stream; to remove them, cancel only the stream that carries them. Nothing prevents a client from instead consolidating onto a single stream by cancelling and re-issuing `events/stream` with the full updated list — cursor replay covers the transition gap — but this is an optimization, not a requirement. Note that on HTTP/1.1 each stream consumes a TCP connection, so clients that expect many independent subscriptions effectively depend on HTTP/2 multiplexing for the multi-stream approach to scale; SDKs SHOULD coalesce subscriptions onto fewer streams when the transport does not multiplex. - **Reconnection after failure.** If the connection drops (HTTP) or the server stops sending (stdio), the client sends a new `events/stream` with the same subscriptions and their last-known cursors. - **Empty subscriptions.** A client may open `events/stream` with an empty `subscriptions` array solely to receive non-event notifications (e.g., `notifications/tools/list_changed`). @@ -297,6 +327,30 @@ The callback URL does not need to be the client itself. A common deployment is a Upstream → MCP Server → webhook POST → Forward Proxy ← Client (poll or push) ``` +```mermaid +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}} + Server-->>SDK: {secret, cursor, refreshBefore} + loop as events occur + Server->>Hook: POST {id, event, cursor} + HMAC signature + Hook-->>Server: 200 OK + Hook--)SDK: deliver event (implementation-defined,
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} + 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. @@ -395,6 +449,7 @@ X-MCP-Timestamp: 1739980800 - `X-MCP-Signature` contains an HMAC-SHA256 signature computed over `timestamp + "." + body` using the shared secret from subscription. The receiver MUST verify this before processing. The `X-MCP-Timestamp` header contains the Unix timestamp (seconds) of the request; the receiver SHOULD reject deliveries older than 5 minutes to prevent replay attacks. - `eventId` in the body enables idempotent processing. The receiver SHOULD deduplicate by this value. - The server SHOULD retry on non-2xx responses with exponential backoff. +- **Acknowledgement semantics.** A `2xx` response from the webhook endpoint is a durability commitment: the endpoint MUST NOT return `2xx` until the event has either been durably persisted (e.g., written to a queue or store the end consumer reads from) or fully processed by the end consumer. The server advances the subscription's cursor on `2xx`; an endpoint that ACKs and then loses the event creates an unrecoverable gap. Endpoints that cannot satisfy this (e.g., a buffering proxy with no persistence) MUST hold the response until the downstream consumer has acknowledged. At-least-once delivery in webhook mode therefore holds between server and endpoint; end-to-end delivery to the agent depends on the endpoint honouring this contract. - **Subscribe/delivery race.** Because the server may begin delivering as soon as the subscription is persisted, the first webhook POST can arrive before the `events/subscribe` response (and thus the `secret`) reaches the client. The recommended handling for now is retry tolerance: a receiver that gets a delivery for an `id` it does not yet recognise, or that it cannot yet verify, SHOULD return a retryable status (e.g., `503` or `425 Too Early`) rather than dropping it; the server's normal retry/backoff machinery will redeliver once the client is ready. `eventId` deduplication and cursor replay make this safe. A two-phase activate handshake was considered and may be added later if this proves insufficient in practice. - After repeated failures (server-defined threshold), the server MAY stop retrying for the remainder of the TTL. The subscription will expire naturally, and the client's next refresh re-establishes it with cursor-based replay. @@ -709,7 +764,7 @@ sub = await client.subscribe("incident.created", params={...}, delivery="webhook **Poll mode:** The SDK runs a background task that calls `events/poll` at the server-recommended interval, batching all subscriptions to the same server into a single request. Events are yielded to the subscription's async iterator. The caller does not implement or manage the polling loop. -**Push mode:** The SDK sends an `events/stream` request and listens for notifications. When subscriptions are added or removed, the SDK cancels the current stream (via `notifications/cancelled` or connection close) and sends a new `events/stream` with the updated subscription list and current cursors. The transition gap is covered by cursor-based replay. +**Push mode:** The SDK sends an `events/stream` request and listens for notifications. When subscriptions are added or removed, the SDK either opens an additional stream for the delta or — when the transport doesn't multiplex well (HTTP/1.1) — cancels the current stream and re-issues `events/stream` with the consolidated list and current cursors. Cursor-based replay covers any transition gap. **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. @@ -820,3 +875,54 @@ What exists today is sufficient: 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. If future use cases involve high-throughput event streams where producer-side backpressure 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. + +```mermaid +sequenceDiagram + autonumber + participant LLM as LLM Agent + participant SDK as Client SDK + participant Proxy as Forward Proxy
(webhook endpoint) + participant Server as GitHub MCP Server + participant Redis as Redis
(sub store, TTL) + participant GH as GitHub.com
(upstream) + + rect rgb(245,245,250) + Note over SDK,Redis: Subscribe (webhook delivery) + SDK->>Server: events/subscribe
{id, name: "pull_request.opened", params: {repo: "acme/webapp"},
delivery: {mode: "webhook", url: "https://proxy/.../hooks"}} + Server->>Redis: SETEX sub:{id} {ttl}
{url, params, secret, cursor} + Server->>GH: ensure repo webhook registered
(server-internal, idempotent) + Server-->>SDK: {secret, cursor, refreshBefore} + end + + rect rgb(245,250,245) + Note over GH,LLM: Event fires + GH->>Server: POST /gh-webhook
{action: "opened", pull_request: {...}} + Server->>Redis: SCAN sub:* → match repo + Redis-->>Server: [{id, url, secret, cursor}] + Server->>Proxy: POST {id, event: {number: 42, title, author, url}, cursor'}
+ HMAC signature + Proxy-->>Server: 200 OK + Proxy--)SDK: deliver event
(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:{id} {ttl} + Server-->>SDK: {refreshBefore'} + end + Note over Redis: no refresh → key expires,
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 cursors cover the gap. + +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 single `notifications/events/event` from server to SDK (see [Push-Based Delivery](#push-based-delivery)). +- **Poll** — drop step 1 and the Forward Proxy lane; the SDK loops `events/poll` with the subscription inline, and step 5–7 become the poll response (see [Poll-Based Delivery](#poll-based-delivery)). From 641eca02a72d828b10f4e76a8fee4f5cacfa23b6 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Fri, 17 Apr 2026 11:52:12 +0100 Subject: [PATCH 03/25] Revise Events design sketch: webhook cursor model, subscription identity, security hardening Resolve internal contradictions and design gaps found in review: - Webhook cursors are now client-owned (per-event retry, no server watermark); name/params/delivery.url are immutable identity fields; unsubscribe requires delivery.url - Unify error/terminated shape across modes; define heartbeat and terminated notifications; renumber error codes to -32011..-32016 to avoid base-spec collision - SSRF: validate at delivery time, block fe80::/10, MUST NOT follow redirects; HMAC: X-MCP-Subscription-Id header, hex-lowercase encoding, mandatory timestamp, retry regenerates - events/stream scoped to event notifications only (existing GET SSE retained); StreamEventsResult send rules clarified per transport - Broadcast emit now uses author-supplied match/transform hooks with ctx; poll lease keyed on (principal, eventName, hash(params)); eventId SHOULD be upstream's stable identifier - Qualify at-least-once for emit-only types; refresh reactivates suspended delivery; capability uses listChanged - Add Open Questions on multi-event-name subscriptions and ownership-verification handshake Add design-sketch-revision-deltas.md documenting all changes for implementations built against the prior revision. --- docs/design-sketch-proposal.md | 190 ++++++++++------- docs/design-sketch-revision-deltas.md | 294 ++++++++++++++++++++++++++ 2 files changed, 405 insertions(+), 79 deletions(-) create mode 100644 docs/design-sketch-revision-deltas.md diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index 7623bb6..583bff3 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -12,8 +12,8 @@ Key design principles: - **Servers advertise delivery modes; no mode is mandatory.** Each event type lists the delivery modes it supports. The client picks the best mode it can use. If there is no overlap between what the server offers and what the client can consume, that event type is simply unavailable to that client — the protocol does not require a universal fallback. (Reference SDKs are still encouraged to make poll cheap to support so that overlap is common in practice.) - **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 subscription state at all. 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 *subscription* state. Servers that relay from upstream sources will still hold other state — upstream credentials, webhook registrations with the upstream, an in-memory event buffer — but none of it is owed to any particular MCP client and all of it is 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 has no subscription state at all. For webhook, the server holds TTL-scoped subscription records, but the client drives their lifecycle via periodic refresh. +- **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. @@ -25,7 +25,7 @@ Servers advertise event support in their capabilities: { "capabilities": { "events": { - "subscribe": true + "listChanged": true } } } @@ -100,20 +100,23 @@ If the set of available event types changes at runtime (e.g., a plugin is loaded There are three delivery modes with different subscription mechanisms: -- **Poll mode:** Client calls `events/poll` with event name, params, and cursor. No separate subscribe step needed — the first poll with a null cursor bootstraps the subscription. Server is fully stateless. +- **Poll mode:** Client calls `events/poll` with 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 POST (`events/stream`) carrying all desired subscriptions. Events are delivered on the SSE response stream (HTTP) or as notifications on stdout (stdio). Connection close terminates all subscriptions. Server state is scoped to connection lifetime. - **Webhook mode:** Client calls `events/subscribe` to 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-calling `events/subscribe` before 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 (applicable to all modes) +### Error Codes | Code | Message | Meaning | |------|---------|---------| | `-32602` | `InvalidParams` | Params don't match inputSchema (standard JSON-RPC invalid params) | -| `-32001` | `EventNotFound` | Unknown event name | -| `-32002` | `Unauthorized` | User lacks permission for this event/params combination | -| `-32003` | `TooManySubscriptions` | Server-imposed subscription limit reached | -| `-32005` | `InvalidCallbackUrl` | Webhook URL is unreachable or rejected by the server | -| `-32006` | `SubscriptionNotFound` | Unknown subscription ID (for `events/unsubscribe`) | +| `-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) | + +These codes occupy `-32011..-32016` to avoid collision with base MCP error codes (e.g., `-32002 ResourceNotFound`). ### Poll-Based Delivery @@ -127,7 +130,7 @@ sequenceDiagram loop every nextPollSeconds SDK->>Server: events/poll {subscriptions[], cursors} - Server-->>SDK: {results: [{events[], cursor}], nextPollSeconds} + Server-->>SDK: {results: [{events[], cursor, nextPollSeconds}]} end Note over SDK: LLM invoked only when
events[] is non-empty ``` @@ -196,12 +199,12 @@ sequenceDiagram - `id` is a client-provided identifier for each subscription. It is opaque to the server and echoed back in responses to allow the client to correlate results with subscriptions. It must be unique within a single `events/poll` request. - `cursor` is opaque to the client. The client stores it and passes it back on the next poll. A `null` cursor means "start from now" — the server returns no events and provides a fresh cursor for subsequent polls. -- `eventId` enables client-side deduplication across polls (e.g., after a crash/restart). +- `eventId` enables client-side deduplication across polls (e.g., after a crash/restart). It is **server-assigned**: when the upstream source provides a stable event identifier (Stripe `evt_*`, GitHub delivery GUID, Kafka offset, Gmail message ID), the server SHOULD use that value as `eventId` so that the same upstream event surfaced via multiple paths (e.g., webhook emit and poll backfill) carries the same `eventId` and dedup works. The SDK auto-generates an `eventId` only when the author supplies none. - `maxEvents` is an optional top-level field that caps the number of events returned per subscription. If more events are available than the limit, the server returns a partial batch with an intermediate cursor and sets `hasMore: true`. The client SHOULD poll again immediately (ignoring `nextPollSeconds`) to drain the backlog. If omitted, the server uses its own default limit. - `hasMore` indicates whether additional events are available beyond the returned batch. When `true`, the client should poll again immediately with the updated cursor. When `false`, the client waits `nextPollSeconds` before the next poll. - `nextPollSeconds` allows the server to dynamically adjust polling frequency per subscription (e.g., back off when rate-limited upstream, speed up when activity is detected). Ignored when `hasMore` is `true`. - Empty `events` array means nothing happened — this is the common case and should be cheap. -- The server holds no 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. (For emit-only event types, the server does hold a ring buffer of recent events — see *Emit-only event types* under Server SDK Guidance — but that buffer is shared infrastructure, not per-subscription state.) +- 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.) #### Error Handling @@ -219,7 +222,7 @@ If an individual subscription within a poll request is invalid, the server retur { "id": "sub_bogus", "error": { - "code": -32001, + "code": -32011, "message": "EventNotFound" } } @@ -233,7 +236,7 @@ Push delivery uses a long-lived `events/stream` request. The client sends all de The transport mechanism differs by transport type: -- **Streamable HTTP:** The `events/stream` request is a POST that returns an SSE response stream. This replaces the GET-based SSE stream — `events/stream` is the sole server→client push channel. Connection close implicitly cancels — no explicit cancellation message is needed. +- **Streamable HTTP:** The `events/stream` request 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_STREAM` on HTTP/2) — no explicit cancellation message is needed. - **stdio:** The `events/stream` request 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 sending `notifications/cancelled` with the request's `id`. ```mermaid @@ -291,27 +294,26 @@ The server confirms each subscription, reports errors for invalid ones, and then {"jsonrpc":"2.0","method":"notifications/events/active","params":{"id":"sub_incidents","cursor":"cursor_abc"}} // Error for invalid subscription (stream remains open for valid ones) -{"jsonrpc":"2.0","method":"notifications/events/error","params":{"id":"sub_bogus","code":-32001,"message":"EventNotFound"}} +{"jsonrpc":"2.0","method":"notifications/events/error","params":{"id":"sub_bogus","error":{"code":-32011,"message":"EventNotFound"}}} // Events as they occur -{"jsonrpc":"2.0","method":"notifications/event","params":{"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"}} +{"jsonrpc":"2.0","method":"notifications/events/event","params":{"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"}} // Final frame when stream closes (StreamEventsResult) {"jsonrpc":"2.0","id":1,"result":{"_meta":{}}} ``` -On Streamable HTTP, notifications are SSE `data:` frames, and the `StreamEventsResult` is the final `data:` frame that closes the request. On stdio, notifications are newline-delimited JSON messages on stdout, and the result is sent when the stream is cancelled. +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, and the result is sent when the stream ends. -Non-event MCP notifications (e.g., `notifications/tools/list_changed`, `notifications/resources/updated`) also flow alongside event notifications. +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 `StreamEventsResult` is an empty typed result (`{"_meta": {}}`) sent as the final message when the stream closes. It carries no information — it satisfies JSON-RPC's requirement that every request gets a response. All meaningful content is in the preceding notifications. -- **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 a `notifications/events/heartbeat` message. The server SHOULD send a heartbeat at least every 30 seconds. The client SHOULD treat absence of any data (events or heartbeats) beyond a threshold (e.g., 60 seconds) as connection failure and reconnect with cursors. -- **Cancellation.** On Streamable HTTP, the client closes the connection. On stdio, the client sends `notifications/cancelled` with the `requestId` matching the `events/stream` request's `id`. In both cases, the server MUST stop delivering events, send the `StreamEventsResult`, and release any associated resources. +- **Stream termination.** The `StreamEventsResult` is 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 stdio (SHOULD), and on Streamable HTTP only when the server initiates the close. +- **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 a `notifications/events/heartbeat` notification with empty params: `{"jsonrpc":"2.0","method":"notifications/events/heartbeat","params":{}}`. The server SHOULD send a heartbeat at least every 30 seconds. The client SHOULD treat absence of any data (events or heartbeats) beyond a threshold (e.g., 60 seconds) as connection failure and reconnect with cursors. +- **Cancellation.** On Streamable HTTP, the client aborts the request stream. On stdio, the client sends `notifications/cancelled` with the `requestId` matching the `events/stream` request's `id`. In both cases, the server MUST stop delivering events and release any associated resources. On stdio, the server SHOULD then send the `StreamEventsResult` (the result is empty and harmless; base MCP says servers SHOULD NOT respond to cancelled requests, so a server that omits it is also compliant). On Streamable HTTP, the abort is the terminal signal and no result is sent. - **Updating subscriptions.** A client MAY hold multiple concurrent `events/stream` requests open, each with its own subscription list. To add subscriptions, open an additional stream; to remove them, cancel only the stream that carries them. Nothing prevents a client from instead consolidating onto a single stream by cancelling and re-issuing `events/stream` with the full updated list — cursor replay covers the transition gap — but this is an optimization, not a requirement. Note that on HTTP/1.1 each stream consumes a TCP connection, so clients that expect many independent subscriptions effectively depend on HTTP/2 multiplexing for the multi-stream approach to scale; SDKs SHOULD coalesce subscriptions onto fewer streams when the transport does not multiplex. - **Reconnection after failure.** If the connection drops (HTTP) or the server stops sending (stdio), the client sends a new `events/stream` with the same subscriptions and their last-known cursors. -- **Empty subscriptions.** A client may open `events/stream` with an empty `subscriptions` array solely to receive non-event notifications (e.g., `notifications/tools/list_changed`). #### Cursor Advancement @@ -334,8 +336,8 @@ sequenceDiagram participant Server as MCP Server participant Hook as Webhook Endpoint - SDK->>Server: events/subscribe {id, name, params, delivery: {url}} - Server-->>SDK: {secret, cursor, refreshBefore} + 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 @@ -346,7 +348,7 @@ sequenceDiagram Server-->>SDK: {refreshBefore'} end opt explicit teardown (else: stop refreshing → TTL expiry) - SDK->>Server: events/unsubscribe {id} + SDK->>Server: events/unsubscribe {id, delivery: {url}} Server-->>SDK: (ack) end ``` @@ -378,7 +380,6 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "secret": "whsec_5c8f...", // present when subscription is (re)created - "cursor": "cursor_start_001", "refreshBefore": "2026-02-19T16:30:00Z" } ``` @@ -388,10 +389,10 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau - `events/subscribe` is ONLY used for webhook delivery. Poll and push do not need it. - `id` is 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. - `secret` is 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 fresh `secret` appears in the response — the client MUST check for its presence on every refresh and update the verifier accordingly. The client MAY supply `delivery.secret` in 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` follows the same semantics as other modes: `null` means "start from now" on a new subscription. When refreshing an existing subscription, `null` means "keep the server's current position"; a non-null value resets the subscription's replay position. +- `cursor` (request only) tells the server where to begin delivery. `null` means "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 track a delivery watermark, and the response does not include a cursor. The client persists the `cursor` carried in each delivered payload (see *Webhook Event Delivery*) and 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. - `refreshBefore` is mandatory. It is an ISO 8601 timestamp indicating when the subscription will expire. The client MUST re-call `events/subscribe` with the same subscription key before this time to keep the subscription alive. The server resets the TTL on each refresh. - `events/subscribe` is 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, cursor) in memory with TTL. No durable storage is required — if the server restarts, clients will re-subscribe on their next refresh cycle, and cursors ensure no events are missed. +- 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 @@ -399,26 +400,27 @@ Webhook subscriptions are keyed by a compound **subscription key** that determin **Key composition.** The subscription key is: -- On servers that authenticate the caller: `(principal, id)`. +- 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. Servers that support both authenticated and anonymous access MUST select the scoping mode per-request: `(principal, id)` whenever a principal is present, `(delivery.url, id)` otherwise. A subscription created under one scope is not visible under the other. +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. key fields.** On an idempotent subscribe against an existing key, the server updates fields as follows: +**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 | |---|---| -| `name`, `params` | Replaced. Changing these redefines what the subscription listens for. | -| `delivery.url` | **Authenticated scope:** replaced — the key is `(principal, id)`, so the URL is mutable. **Unauthenticated scope:** part of the key — a different URL addresses a different subscription. | | `delivery.secret` | Replaced if supplied (client-driven rotation / override). If omitted, the existing secret is unchanged and is not returned. | -| `cursor` | Replaced if non-null; ignored if null (the server keeps its current position — a refresh loop typically passes `null`). | +| `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 either `principal` or `delivery.url`, 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. +**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 @@ -427,7 +429,8 @@ 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= +X-MCP-Subscription-Id: f47ac10b-58cc-4372-a567-0e02b2c3d479 +X-MCP-Signature: sha256= X-MCP-Timestamp: 1739980800 { @@ -446,12 +449,13 @@ X-MCP-Timestamp: 1739980800 **Notes:** -- `X-MCP-Signature` contains an HMAC-SHA256 signature computed over `timestamp + "." + body` using the shared secret from subscription. The receiver MUST verify this before processing. The `X-MCP-Timestamp` header contains the Unix timestamp (seconds) of the request; the receiver SHOULD reject deliveries older than 5 minutes to prevent replay attacks. +- `X-MCP-Subscription-Id` carries the subscription `id` so the receiver can select the correct secret before parsing the body. `X-MCP-Signature` contains an HMAC-SHA256 signature (lowercase hex) computed over `timestamp + "." + body` using the shared secret from subscription. The receiver MUST verify this before processing. The `X-MCP-Timestamp` header 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. - `eventId` in the body enables idempotent processing. The receiver SHOULD deduplicate by this value. -- The server SHOULD retry on non-2xx responses with exponential backoff. -- **Acknowledgement semantics.** A `2xx` response from the webhook endpoint is a durability commitment: the endpoint MUST NOT return `2xx` until the event has either been durably persisted (e.g., written to a queue or store the end consumer reads from) or fully processed by the end consumer. The server advances the subscription's cursor on `2xx`; an endpoint that ACKs and then loses the event creates an unrecoverable gap. Endpoints that cannot satisfy this (e.g., a buffering proxy with no persistence) MUST hold the response until the downstream consumer has acknowledged. At-least-once delivery in webhook mode therefore holds between server and endpoint; end-to-end delivery to the agent depends on the endpoint honouring this contract. +- `cursor` in the body is the subscription's position *after* this event. The endpoint MUST make `cursor` and `eventId` available 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-`2xx` responses, and does not track a server-side cursor watermark. 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 uses `eventId` for deduplication and `timestamp` for ordering if needed. +- **Acknowledgement semantics.** A `2xx` response from the webhook endpoint signals that the event has been accepted and the server need not retry it. The endpoint SHOULD NOT return `2xx` until 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.** Because the server may begin delivering as soon as the subscription is persisted, the first webhook POST can arrive before the `events/subscribe` response (and thus the `secret`) reaches the client. The recommended handling for now is retry tolerance: a receiver that gets a delivery for an `id` it does not yet recognise, or that it cannot yet verify, SHOULD return a retryable status (e.g., `503` or `425 Too Early`) rather than dropping it; the server's normal retry/backoff machinery will redeliver once the client is ready. `eventId` deduplication and cursor replay make this safe. A two-phase activate handshake was considered and may be added later if this proves insufficient in practice. -- After repeated failures (server-defined threshold), the server MAY stop retrying for the remainder of the TTL. The subscription will expire naturally, and the client's next refresh re-establishes it with cursor-based replay. +- After repeated failures (server-defined threshold), the server MAY suspend delivery (`deliveryStatus.active: false`). A subsequent successful refresh reactivates it (sets `active: true`) and the server resumes retrying pending events; if the client never refreshes, the subscription expires naturally at TTL. #### Webhook Delivery Status @@ -461,7 +465,6 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre // Healthy subscription refresh { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "cursor": "cursor_xyz", "refreshBefore": "2026-02-19T17:00:00Z", "deliveryStatus": { "active": true, @@ -475,7 +478,6 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre // Subscription with delivery failures { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "cursor": "cursor_xyz", "refreshBefore": "2026-02-19T17:00:00Z", "deliveryStatus": { "active": false, @@ -486,15 +488,15 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre } ``` -`deliveryStatus` is OPTIONAL — servers MAY omit it entirely. When present, `active` indicates whether the server is currently delivering events (`false` means it has stopped retrying after repeated failures). `lastError` provides a human-readable description of the most recent failure. The client can use this information to diagnose connectivity or authentication issues with the webhook endpoint. +`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` provides a human-readable description of the most recent failure. 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 at subscribe time. Servers SHOULD reject URLs pointing to private or loopback address ranges (`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`) unless explicitly configured to allow them. The server SHOULD resolve the hostname and validate the resolved IP before accepting the subscription to prevent DNS rebinding attacks. Servers MAY maintain an allowlist of permitted callback URL patterns. +**SSRF prevention.** The server MUST validate callback URLs. Servers SHOULD reject URLs whose resolved IP falls in private, loopback, or link-local address ranges (`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. **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 SHOULD 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: `HMAC-SHA256(secret, timestamp + "." + body)`. The receiver SHOULD reject deliveries where the timestamp is more than 5 minutes old. This prevents captured webhook payloads from being replayed. +**Replay attack prevention.** The webhook POST MUST include an `X-MCP-Subscription-Id` header (the subscription `id`, so the receiver can select the correct secret without parsing the body) and 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=` where the value is `HMAC-SHA256(secret, timestamp + "." + body)` encoded as lowercase hexadecimal. 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. **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. @@ -509,20 +511,6 @@ HMAC is verified by application code after the request has been routed. It does `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. ```jsonc -{ - "jsonrpc": "2.0", - "method": "events/unsubscribe", - "id": 3, - "params": { - "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" - } -} -``` - -`events/unsubscribe` resolves the subscription using the same scoped key as `events/subscribe`. For authenticated callers, `(principal, id)` is sufficient and the payload above is complete. For unauthenticated callers, the client MUST also include `delivery.url` so the server can form the `(delivery.url, id)` key: - -```jsonc -// Unauthenticated server { "jsonrpc": "2.0", "method": "events/unsubscribe", @@ -534,6 +522,8 @@ HMAC is verified by application code after the request has been routed. It does } ``` +`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 @@ -556,7 +546,7 @@ For poll mode, this appears in the per-subscription results: { "id": "sub_email", "error": { - "code": -32004, + "code": -32014, "message": "CursorExpired", "data": { "reason": "Upstream history compacted" } } @@ -566,7 +556,18 @@ For poll mode, this appears in the per-subscription results: For push mode, this appears as a notification on the stream: ``` -data: {"jsonrpc":"2.0","method":"notifications/events/error","params":{"id":"sub_email","code":-32004,"message":"CursorExpired","data":{"reason":"Upstream history compacted"}}} +data: {"jsonrpc":"2.0","method":"notifications/events/error","params":{"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-Subscription-Id: f47ac10b-58cc-4372-a567-0e02b2c3d479 +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`. @@ -604,7 +605,7 @@ async def check_email(context, params, cursor): data = {"messageId": msg.id, "from": msg.sender, ...} if params.get("redact_pii"): data = redact(data) - events.append(Event(name="email.received", data=data)) + events.append(Event(name="email.received", eventId=msg.id, data=data)) return EventResult(events=events, cursor=history.historyId) ``` @@ -619,7 +620,7 @@ The SDK calls this function in different contexts depending on the delivery mode 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-known cursor to resume without missing events. This is by design: the mandatory TTL + refresh mechanism eliminates the need for durable subscription storage. +**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. ```python # Webhook mode uses in-memory subscription state with TTL. @@ -631,11 +632,28 @@ server = MCPServer( **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 matches the event against all active subscriptions' params and delivers to those that match. This is appropriate when the upstream source delivers all events regardless of subscription params (e.g., a PagerDuty webhook that fires for all incidents — the SDK filters by `severity`). +- **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) -> bool` decides whether the subscription receives the event. If absent, all subscriptions for that event name receive it. + - `transform(ctx, event, params) -> event` shapes the payload for that subscription (e.g., apply `redact_pii`, expand or thin the payload per a subscriber's `expand` param). If absent, the event is delivered as emitted. + + `ctx` carries the subscription's principal and request metadata. Because param semantics are author-defined (filters, globs, transforms — see `inputSchema`), the SDK cannot evaluate them generically; these hooks let the author own both filtering and shaping. The SDK applies the same hooks when an `events/poll` request 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 — `match` filters by `severity`, `transform` redacts 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. ```python -# Broadcast emit — SDK filters by subscription params. +@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", @@ -696,7 +714,7 @@ async def on_unsubscribe(context, params, subscription_id): 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. -**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. `on_subscribe` fires the first time a given `(principal, subscription id)` appears in a poll request; each subsequent poll for that id renews the lease; `on_unsubscribe` fires when the lease expires without renewal. 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: +**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: ```python server = MCPServer( @@ -794,13 +812,23 @@ This reduces PII exposure in event infrastructure (logs, queues, buffers) and li **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 notifies the client. For push mode, this is sent on the SSE stream: +**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 | Error entry in the poll response for that subscription | +| Webhook | Signed error envelope POSTed to the callback URL, plus `deliveryStatus.lastError` on next refresh | ``` -data: {"jsonrpc":"2.0","method":"notifications/events/terminated","params":{"id":"sub_slack","reason":"Access revoked"}} +// Push +data: {"jsonrpc":"2.0","method":"notifications/events/terminated","params":{"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"}}} ``` -For poll mode, this appears as an error in the poll response for that subscription. The client SDK SHOULD remove the subscription and notify the application. +`notifications/events/terminated` has the shape `{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. @@ -830,7 +858,7 @@ The spec does not mandate specific governance mechanisms but is designed to enab - **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 cursors are used correctly (the client replays from the last known cursor on reconnect/restart). Exactly-once requires application-level deduplication via `eventId`. +- **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 @@ -842,7 +870,11 @@ The spec does not mandate specific governance mechanisms but is designed to enab 4. **Should webhook delivery adopt the Standard Webhooks format instead of defining its own?** [Standard Webhooks](https://www.standardwebhooks.com/) specifies a delivery envelope very close to what this doc defines: `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers, `HMAC-SHA256(secret, id + "." + timestamp + "." + body)` with a `whsec_` prefix on secrets, base64 `v1,` 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 5 below for free. The cost is losing the `X-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 subscription `id` carried as `webhook-id`. -5. **Should webhook deliveries support multiple signatures for zero-downtime secret rotation?** The current design has a single `X-MCP-Signature` header, and rotation happens via an atomic upsert of `delivery.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=,v1=,v1=`) 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 allow `delivery.secret` to be an array (or require servers to dual-sign for N seconds after rotation)? +5. **Can a single subscription span multiple event names with one cursor?** 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_changed` and `k8s.oom_killed` opens 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's `name` to 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. + +6. **Should webhook subscription require an ownership-verification handshake?** Before activating delivery, the server would POST a challenge token to `delivery.url` and require the endpoint to echo it back (cf. Slack's URL verification, SNS `SubscriptionConfirmation`). This proves the subscriber controls the endpoint, preventing a client from pointing deliveries at a third party. Cost: an extra round-trip and an endpoint-side requirement. The current SSRF defenses (blocklist, no-redirect, delivery-time IP validation) mitigate internal-target abuse but not third-party-target abuse. + +7. **Should webhook deliveries support multiple signatures for zero-downtime secret rotation?** The current design has a single `X-MCP-Signature` header, and rotation happens via an atomic upsert of `delivery.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=,v1=,v1=`) 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 allow `delivery.secret` to be an array (or require servers to dual-sign for N seconds after rotation)? ## Note on Consistency and Ordering @@ -853,8 +885,8 @@ The rationale is that the protocol connects to many different upstream systems ( What the protocol *does* provide: - **Cursors** for resumability — the client can pick up where it left off after a disconnect or crash. -- **`eventId`** for client-side deduplication — the client can detect and discard duplicates that arise during reconnection. -- **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 — concurrent HTTP requests may arrive out of order, and retries can reorder deliveries. Clients that need ordering can use the `timestamp` field in event payloads. No ordering is guaranteed across subscriptions in any mode. +- **`eventId`** for 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 and the server does not track a delivery watermark, so concurrent requests and retries can reorder arrivals (this matches Stripe, GitHub, Shopify, and the Standard Webhooks spec). Clients that need ordering use the `timestamp` field 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). @@ -874,7 +906,7 @@ What exists today is sufficient: 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. -If future use cases involve high-throughput event streams where producer-side backpressure becomes necessary, a credit-based flow control extension can be added without breaking the existing protocol. +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) @@ -893,7 +925,7 @@ sequenceDiagram rect rgb(245,245,250) Note over SDK,Redis: Subscribe (webhook delivery) SDK->>Server: events/subscribe
{id, name: "pull_request.opened", params: {repo: "acme/webapp"},
delivery: {mode: "webhook", url: "https://proxy/.../hooks"}} - Server->>Redis: SETEX sub:{id} {ttl}
{url, params, secret, cursor} + Server->>Redis: SETEX sub:{principal}:{urlHash}:{id} {ttl}
{name, params, url, secret} Server->>GH: ensure repo webhook registered
(server-internal, idempotent) Server-->>SDK: {secret, cursor, refreshBefore} end @@ -902,7 +934,7 @@ sequenceDiagram Note over GH,LLM: Event fires GH->>Server: POST /gh-webhook
{action: "opened", pull_request: {...}} Server->>Redis: SCAN sub:* → match repo - Redis-->>Server: [{id, url, secret, cursor}] + Redis-->>Server: [{id, url, secret}] Server->>Proxy: POST {id, event: {number: 42, title, author, url}, cursor'}
+ HMAC signature Proxy-->>Server: 200 OK Proxy--)SDK: deliver event
(implementation-defined, e.g. poll/push) @@ -912,13 +944,13 @@ sequenceDiagram rect rgb(250,245,245) Note over SDK,Redis: Keepalive SDK->>Server: events/subscribe (same key — refresh TTL) - Server->>Redis: EXPIRE sub:{id} {ttl} + Server->>Redis: EXPIRE sub:{principal}:{urlHash}:{id} {ttl} Server-->>SDK: {refreshBefore'} end Note over Redis: no refresh → key expires,
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 cursors cover the gap. +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. diff --git a/docs/design-sketch-revision-deltas.md b/docs/design-sketch-revision-deltas.md new file mode 100644 index 0000000..ecc4779 --- /dev/null +++ b/docs/design-sketch-revision-deltas.md @@ -0,0 +1,294 @@ +# Design Sketch Revision — Implementation Deltas + +This document lists every change between the previous version of `design-sketch-proposal.md` (commit `4f8a112`) and the current revision, written for an implementation that was built against the previous version. Each delta states what changed, why, and what code needs to change. + +Read alongside `git diff 4f8a112 -- docs/design-sketch-proposal.md` for exact wording. + +--- + +## 1. Webhook cursor model — server no longer tracks a watermark **(breaking)** + +**Was:** Server stored a per-subscription cursor and advanced it on each `2xx` ack. `events/subscribe` response included `"cursor"`. Refresh with `cursor: null` meant "keep the server's current position." + +**Now:** Cursor is **client-owned**. Server does not track a delivery position at all. +- `events/subscribe` **request** still accepts `cursor` (`null` = start from now; non-null = replay from there if upstream is durable). +- `events/subscribe` **response** no longer contains `cursor`. Remove the field from `SubscribeEventsResult`. +- Webhook delivery **payload** body still carries `cursor` (position after this event). Unchanged on the wire, but now the *only* place the client gets a cursor. +- Server retries each event independently with exponential backoff on non-`2xx`. No watermark advancement, no head-of-line blocking logic. +- Refresh with `cursor: null` now means "leave delivery uninterrupted" (no server position to keep). + +**Why:** The previous model was unsound under out-of-order acks (event B acks while A retries → cursor=5 but A undelivered → gap on restart). Per-event retry without a server watermark matches Stripe/GitHub/Shopify/Standard Webhooks. + +**Code changes:** +- Delete server-side per-subscription `cursor` field and all "advance on 2xx" logic. +- Remove `cursor` from the subscribe response struct and both response examples. +- Remove `cursor` from `deliveryStatus`. +- Delivery worker: drop any serialization/ordering on cursor; fire each event with its own retry schedule. +- Client SDK: persist `cursor` from each delivered payload; pass it back on resubscribe. + +--- + +## 2. Webhook subscription key — `delivery.url` is always part of the key **(breaking)** + +**Was:** Authenticated scope keyed on `(principal, id)` with `delivery.url` mutable on refresh. Unauthenticated scope keyed on `(delivery.url, id)`. + +**Now:** Both scopes include `delivery.url` in the key: +- Authenticated: `(principal, delivery.url, id)` +- Unauthenticated: `(delivery.url, id)` + +`delivery.url` is therefore **immutable** for a subscription's lifetime in all scopes. To change endpoint: `events/unsubscribe` then `events/subscribe`. + +**Why:** Previously, the same client code (refresh with new URL) mutated in-place when authenticated but silently forked a second subscription when unauthenticated. Making URL always part of the key gives consistent behavior. + +**Code changes:** +- Change the subscription map key type to include `delivery.url` in the authenticated path. +- Remove the "update URL in place on refresh" branch. +- Update the Mutable Fields handling: a refresh with a different URL now addresses a *different* subscription (i.e., creates one). + +--- + +## 3. `events/stream` no longer replaces the GET SSE stream **(breaking if you removed GET SSE)** + +**Was:** `events/stream` was "the sole server→client push channel," replacing the transport's GET-based SSE stream. Non-event notifications (`tools/list_changed`, progress, logging) rode on `events/stream`. Clients could open it with empty `subscriptions` just to receive those. + +**Now:** `events/stream` carries **only** `notifications/events/*`. The existing GET SSE stream is retained unchanged for all non-event server-initiated notifications. The "empty subscriptions" use-case is removed. + +**Why:** The previous framing was a sweeping base-transport change buried in a sub-bullet. Walking it back keeps this proposal additive. + +**Code changes:** +- If you removed/redirected the GET SSE endpoint: restore it. +- Stop routing `tools/list_changed`, `resources/updated`, progress, and logging notifications onto `events/stream` responses — route them as before. +- Reject or no-op `events/stream` with an empty `subscriptions` array (it has no purpose now); or accept it and just heartbeat. + +--- + +## 4. Poll lease key — now `(principal, eventName, canonicalHash(params))` **(breaking for SDK internals)** + +**Was:** Lease table keyed on `(principal, subscription id)` from the poll request. + +**Now:** Keyed on `(principal, eventName, canonicalHash(params))`. The request `id` is **not** used — it remains opaque and request-scoped per the wire contract. + +**Why:** The `id` is only required to be unique within a single poll request. A compliant client can reuse `id: "1"` for different subscriptions across requests, which would corrupt an id-keyed lease table. + +**Code changes:** +- Change the lease map key. Implement `canonicalHash(params)` (stable JSON canonicalization → SHA-256, or equivalent). +- `on_subscribe` fires on first appearance of the new key; renewal and expiry logic unchanged otherwise. +- Add a note in your SDK that lease state is ephemeral: never persisted; restart re-fires `on_subscribe` on next poll. + +--- + +## 5. Broadcast emit — requires author-supplied `match()` and `transform()` hooks **(breaking for SDK API)** + +**Was:** "The SDK matches the event against all active subscriptions' params." + +**Now:** Event registration takes two optional callbacks: +- `match(event, params) -> bool` — decides whether a subscription receives the event. If absent, all subscriptions for that event name receive it. +- `transform(event, params) -> event` — shapes the payload per subscription (e.g., apply `redact_pii`, honour an `expand` param). If absent, the event is delivered as emitted. + +On broadcast emit, the SDK iterates active subscriptions for that event name, calls `match`, and on `True` delivers `transform(event, params)`. + +**Why:** Params are author-defined (globs, transforms, dedup windows). The SDK cannot generically evaluate or apply them. + +**Code changes:** +- Add `match: Callable[[Event, dict], bool] | None` and `transform: Callable[[Event, dict], Event] | None` to the event registration API. +- Broadcast fan-out: `if match is None or match(event, sub.params): deliver(sub, transform(event, sub.params) if transform else event)`. + +--- + +## 6. DNS rebinding mitigation — validate at delivery time, not subscribe time **(security)** + +**Was:** Resolve hostname and validate IP once, at subscribe time. + +**Now:** On **every delivery**: resolve hostname → validate resolved IP against private/loopback blocklist → connect directly to that validated IP, sending the original hostname in `Host` header / TLS SNI. Subscribe-time validation MAY remain as an early-reject convenience but is not the security boundary. + +**Why:** Subscribe-time-only resolution is exactly what DNS rebinding defeats (public IP at validate time, private IP at delivery time). + +**Code changes:** +- Move IP validation into the delivery path. +- Use a resolve-then-connect-to-IP pattern (most HTTP clients support a custom resolver or socket-level connect with `Host`/SNI override). + +--- + +## 7. `X-MCP-Timestamp` header — SHOULD → MUST + +**Was:** Header was SHOULD; signature formula required it anyway. + +**Now:** Header is MUST. Signature formula unchanged: `HMAC-SHA256(secret, timestamp + "." + body)`. + +**Code changes:** +- Always send the header. Receiver: reject deliveries missing it. + +--- + +## 8. `StreamEventsResult` — only sent when the server can write a final frame + +**Was:** "Server MUST send `StreamEventsResult`" on cancellation, unconditionally. + +**Now:** +- stdio: server MUST send `StreamEventsResult` when the stream ends (either side). +- Streamable HTTP, **server-initiated** close: send `StreamEventsResult` as the final SSE `data:` frame. +- Streamable HTTP, **client-initiated** close (connection drop): no result is sent (physically can't). + +**Code changes:** +- HTTP handler: on client disconnect, just clean up — don't attempt to write the result. On server-initiated shutdown, write result then close. +- stdio handler: unchanged. + +--- + +## 9. Webhook `CursorExpired` and `terminated` — now POSTed to the endpoint + +**Was:** These error paths were specified for poll and push only; webhook was silent. + +**Now:** When a webhook subscription hits `CursorExpired` (`-32014`) or is terminated (e.g., `-32012 Unauthorized`), the server POSTs a signed error envelope to the callback URL: + +``` +POST +X-MCP-Signature: sha256=<...> +X-MCP-Timestamp: + +{"id":"","error":{"code":-32014,"message":"CursorExpired","data":{"reason":"..."}}} +``` + +It also surfaces the error in `deliveryStatus.lastError` on the next refresh response. + +**Code changes:** +- Add an error-delivery path that builds this envelope, signs it like a normal delivery, and POSTs it. +- Endpoint/receiver: handle a body with top-level `error` instead of `data`. + +--- + +## 10. Endpoint forwarding requirement — `cursor` and `eventId` MUST reach the client + +New normative requirement on the webhook **endpoint** (the receiver you POST to): it MUST make `cursor` and `eventId` available to the consuming client by whatever channel it forwards events. + +**Code changes (endpoint/relay implementations):** ensure both fields are passed through, not dropped. + +--- + +## 11. "No subscription state" wording softened — no behavior change + +The doc now says poll holds "no **protocol-required** subscription state" and explicitly permits the SDK to hold ephemeral derived state (lease table, emit ring buffer). If your SDK already holds these, you're now explicitly compliant. **No code change** — documentation alignment only. + +--- + +## 12. At-least-once guarantee qualified — no behavior change + +The guarantee now reads: at-least-once **when the cursor is backed by a durable upstream**; emit-only event types are **at-most-once across server restarts**. If you advertised unconditional at-least-once anywhere (docs, capability metadata), qualify it. **No runtime code change.** + +--- + +## 13. Push-mode reconnect backlog — acknowledged as a v1 gap + +Flow Control section now notes that push reconnect with a stale cursor can dump a large backlog with no protocol-level bound (poll has `maxEvents`/`hasMore`; push does not). Left to TCP backpressure / server-side pacing for v1. + +**Code changes:** none required. Optionally, add server-side pacing on backlog replay. + +--- + +## 14. `eventId` is server-assignable from upstream identifiers **(SDK API)** + +**Was:** Unstated; SDKs typically auto-generated `eventId`. + +**Now:** Spec clarifies `eventId` SHOULD be the upstream's stable event identifier (Stripe `evt_*`, GitHub delivery GUID, Kafka offset, Gmail message ID) when one exists. SDK auto-generates only when the author supplies none. + +**Why:** Dual-path sources (e.g., Stripe webhook emit + poll backfill) otherwise surface the same upstream event with two different SDK-generated IDs, breaking client dedup. + +**Code changes:** +- `Event` constructor / `emit()` / `check()` return: accept an optional `eventId` from the author. Only generate a UUID when it's omitted. + +--- + +## 15. Wire-format corrections **(breaking on the wire)** + +| Item | Was | Now | Action | +|---|---|---|---| +| Event notification method | `notifications/event` (in one example) | `notifications/events/event` everywhere | Rename the method constant if you copied from the JSON example | +| `nextPollSeconds` placement | Top-level in one diagram | Per-result entry only | Ensure your `PollEventsResult` has it inside each `results[i]`, not at top level | +| Error codes | `-32001..-32006` | `-32011..-32016` (shifted to avoid collision with base MCP `-32002 ResourceNotFound`); `CursorExpired` (`-32014`) now listed in the table | Renumber your error-code enum: `EventNotFound -32011`, `Unauthorized -32012`, `TooManySubscriptions -32013`, `CursorExpired -32014`, `InvalidCallbackUrl -32015`, `SubscriptionNotFound -32016` | +| `notifications/events/heartbeat` | Named, shape undefined | `{"jsonrpc":"2.0","method":"notifications/events/heartbeat","params":{}}` | Emit with empty `params` object | + +--- + +## 16. `events/unsubscribe` requires `delivery.url` **(breaking on the wire)** + +Consequence of #2. `delivery.url` is now part of the subscription key in all scopes, so `events/unsubscribe` MUST include it alongside `id`. Previously it was optional for authenticated callers. + +**Code changes:** Add `delivery: {url}` to the unsubscribe request schema (required). Server forms the full key `(principal?, delivery.url, id)`. If you keep a Redis-style store, key it on `sub:{principal}:{urlHash}:{id}`. + +--- + +## 17. Webhook `name`/`params` are immutable **(breaking)** + +`name` and `params` join `delivery.url` as immutable identity fields. A refresh that supplies different values addresses a different subscription (treated as create). To change what a subscription listens for: unsubscribe + resubscribe. + +**Why:** Mutating them in place left the upstream listener (provisioned by `on_subscribe`) bound to old params, silently delivering nothing. + +**Code changes:** Remove the "replace `name`/`params` on existing key" branch. Only `delivery.secret`, `cursor`, TTL, and `active` are mutable on refresh. + +--- + +## 18. Webhook refresh behavior — always pass cursor; refresh reactivates delivery + +Two clarifications to #1: +- Clients pass their **last-persisted cursor on every refresh** (not `null`). Server is idempotent: if the sub is live and the cursor is at/behind in-flight position, no-op; if lapsed/restarted, replay from there. +- A successful refresh **sets `active: true`**. If delivery was suspended after repeated failures, refresh resumes it. + +**Code changes:** Client SDK refresh loop sends persisted cursor, not `null`. Server: on refresh, clear the suspended flag and resume retry of pending events. + +--- + +## 19. Unified error/terminated shape across modes **(wire change)** + +`notifications/events/error` (push) now nests under `params.error` instead of flat `params.code`/`params.message`: +``` +{"method":"notifications/events/error","params":{"id":"...","error":{"code":-32011,"message":"..."}}} +``` +`notifications/events/terminated` is now defined with the same shape (`{id, error: {code, message, data?}}`). Poll error entries and webhook error POSTs already use this nested shape. + +**Code changes:** Update push error notification builder. Add `notifications/events/terminated` to your push message types. + +--- + +## 20. Webhook delivery headers + signature encoding **(wire change)** + +- Add `X-MCP-Subscription-Id: ` header so receivers select the secret before parsing the body. +- Signature header is exactly `X-MCP-Signature: sha256=`. +- Each retry regenerates `X-MCP-Timestamp` and the signature. + +**Code changes:** Add the header; lowercase-hex encode the HMAC; move timestamp+sign into the per-attempt path. + +--- + +## 21. SSRF hardening — `fe80::/10` + no redirects **(security)** + +Extends #6. Add `fe80::/10` (IPv6 link-local) to the blocklist. Webhook delivery requests MUST NOT follow HTTP redirects. + +--- + +## 22. `match`/`transform` signature + scope **(SDK API change)** + +Extends #5. Signatures are now `match(ctx, event, params)` / `transform(ctx, event, params)` (`ctx` carries principal + request metadata). The SDK applies these hooks not only on broadcast emit but also when an `events/poll` request reads from the emit-only ring buffer. + +--- + +## 23. Capability object — `listChanged` instead of `subscribe` **(wire change)** + +Events capability is `{"events": {"listChanged": true}}`. Delivery modes are advertised per-event in `deliveryModes`, not at the capability level. + +--- + +## 24. Cancellation wording + +HTTP cancellation = abort request stream (TCP close on HTTP/1.1, `RST_STREAM` on HTTP/2). stdio `StreamEventsResult` after cancel is now SHOULD (was MUST in #8's first pass) to align with base MCP's SHOULD-NOT-respond-after-cancel. + +--- + +## Quick triage + +If you need to prioritize: + +1. **Security:** #6 (DNS rebinding), #7 (timestamp MUST), #21 (`fe80::/10`, no redirects) +2. **Wire-breaking:** #1, #2, #3, #15, #16 (unsubscribe needs url), #17 (name/params immutable), #19 (error shape), #20 (headers/encoding), #23 (capability) +3. **SDK-internal breaking:** #4 (lease key), #5/#22 (`match`/`transform` hooks + ctx), #14 (`eventId` passthrough), #18 (refresh cursor/reactivate) +4. **Additive:** #8, #9, #10 +5. **Docs-only/minor:** #11, #12, #13, #24 From 3dfc90566d1882e5af203d645bf838cb18b77855 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Fri, 17 Apr 2026 11:53:51 +0100 Subject: [PATCH 04/25] Remove revision deltas doc --- docs/design-sketch-revision-deltas.md | 294 -------------------------- 1 file changed, 294 deletions(-) delete mode 100644 docs/design-sketch-revision-deltas.md diff --git a/docs/design-sketch-revision-deltas.md b/docs/design-sketch-revision-deltas.md deleted file mode 100644 index ecc4779..0000000 --- a/docs/design-sketch-revision-deltas.md +++ /dev/null @@ -1,294 +0,0 @@ -# Design Sketch Revision — Implementation Deltas - -This document lists every change between the previous version of `design-sketch-proposal.md` (commit `4f8a112`) and the current revision, written for an implementation that was built against the previous version. Each delta states what changed, why, and what code needs to change. - -Read alongside `git diff 4f8a112 -- docs/design-sketch-proposal.md` for exact wording. - ---- - -## 1. Webhook cursor model — server no longer tracks a watermark **(breaking)** - -**Was:** Server stored a per-subscription cursor and advanced it on each `2xx` ack. `events/subscribe` response included `"cursor"`. Refresh with `cursor: null` meant "keep the server's current position." - -**Now:** Cursor is **client-owned**. Server does not track a delivery position at all. -- `events/subscribe` **request** still accepts `cursor` (`null` = start from now; non-null = replay from there if upstream is durable). -- `events/subscribe` **response** no longer contains `cursor`. Remove the field from `SubscribeEventsResult`. -- Webhook delivery **payload** body still carries `cursor` (position after this event). Unchanged on the wire, but now the *only* place the client gets a cursor. -- Server retries each event independently with exponential backoff on non-`2xx`. No watermark advancement, no head-of-line blocking logic. -- Refresh with `cursor: null` now means "leave delivery uninterrupted" (no server position to keep). - -**Why:** The previous model was unsound under out-of-order acks (event B acks while A retries → cursor=5 but A undelivered → gap on restart). Per-event retry without a server watermark matches Stripe/GitHub/Shopify/Standard Webhooks. - -**Code changes:** -- Delete server-side per-subscription `cursor` field and all "advance on 2xx" logic. -- Remove `cursor` from the subscribe response struct and both response examples. -- Remove `cursor` from `deliveryStatus`. -- Delivery worker: drop any serialization/ordering on cursor; fire each event with its own retry schedule. -- Client SDK: persist `cursor` from each delivered payload; pass it back on resubscribe. - ---- - -## 2. Webhook subscription key — `delivery.url` is always part of the key **(breaking)** - -**Was:** Authenticated scope keyed on `(principal, id)` with `delivery.url` mutable on refresh. Unauthenticated scope keyed on `(delivery.url, id)`. - -**Now:** Both scopes include `delivery.url` in the key: -- Authenticated: `(principal, delivery.url, id)` -- Unauthenticated: `(delivery.url, id)` - -`delivery.url` is therefore **immutable** for a subscription's lifetime in all scopes. To change endpoint: `events/unsubscribe` then `events/subscribe`. - -**Why:** Previously, the same client code (refresh with new URL) mutated in-place when authenticated but silently forked a second subscription when unauthenticated. Making URL always part of the key gives consistent behavior. - -**Code changes:** -- Change the subscription map key type to include `delivery.url` in the authenticated path. -- Remove the "update URL in place on refresh" branch. -- Update the Mutable Fields handling: a refresh with a different URL now addresses a *different* subscription (i.e., creates one). - ---- - -## 3. `events/stream` no longer replaces the GET SSE stream **(breaking if you removed GET SSE)** - -**Was:** `events/stream` was "the sole server→client push channel," replacing the transport's GET-based SSE stream. Non-event notifications (`tools/list_changed`, progress, logging) rode on `events/stream`. Clients could open it with empty `subscriptions` just to receive those. - -**Now:** `events/stream` carries **only** `notifications/events/*`. The existing GET SSE stream is retained unchanged for all non-event server-initiated notifications. The "empty subscriptions" use-case is removed. - -**Why:** The previous framing was a sweeping base-transport change buried in a sub-bullet. Walking it back keeps this proposal additive. - -**Code changes:** -- If you removed/redirected the GET SSE endpoint: restore it. -- Stop routing `tools/list_changed`, `resources/updated`, progress, and logging notifications onto `events/stream` responses — route them as before. -- Reject or no-op `events/stream` with an empty `subscriptions` array (it has no purpose now); or accept it and just heartbeat. - ---- - -## 4. Poll lease key — now `(principal, eventName, canonicalHash(params))` **(breaking for SDK internals)** - -**Was:** Lease table keyed on `(principal, subscription id)` from the poll request. - -**Now:** Keyed on `(principal, eventName, canonicalHash(params))`. The request `id` is **not** used — it remains opaque and request-scoped per the wire contract. - -**Why:** The `id` is only required to be unique within a single poll request. A compliant client can reuse `id: "1"` for different subscriptions across requests, which would corrupt an id-keyed lease table. - -**Code changes:** -- Change the lease map key. Implement `canonicalHash(params)` (stable JSON canonicalization → SHA-256, or equivalent). -- `on_subscribe` fires on first appearance of the new key; renewal and expiry logic unchanged otherwise. -- Add a note in your SDK that lease state is ephemeral: never persisted; restart re-fires `on_subscribe` on next poll. - ---- - -## 5. Broadcast emit — requires author-supplied `match()` and `transform()` hooks **(breaking for SDK API)** - -**Was:** "The SDK matches the event against all active subscriptions' params." - -**Now:** Event registration takes two optional callbacks: -- `match(event, params) -> bool` — decides whether a subscription receives the event. If absent, all subscriptions for that event name receive it. -- `transform(event, params) -> event` — shapes the payload per subscription (e.g., apply `redact_pii`, honour an `expand` param). If absent, the event is delivered as emitted. - -On broadcast emit, the SDK iterates active subscriptions for that event name, calls `match`, and on `True` delivers `transform(event, params)`. - -**Why:** Params are author-defined (globs, transforms, dedup windows). The SDK cannot generically evaluate or apply them. - -**Code changes:** -- Add `match: Callable[[Event, dict], bool] | None` and `transform: Callable[[Event, dict], Event] | None` to the event registration API. -- Broadcast fan-out: `if match is None or match(event, sub.params): deliver(sub, transform(event, sub.params) if transform else event)`. - ---- - -## 6. DNS rebinding mitigation — validate at delivery time, not subscribe time **(security)** - -**Was:** Resolve hostname and validate IP once, at subscribe time. - -**Now:** On **every delivery**: resolve hostname → validate resolved IP against private/loopback blocklist → connect directly to that validated IP, sending the original hostname in `Host` header / TLS SNI. Subscribe-time validation MAY remain as an early-reject convenience but is not the security boundary. - -**Why:** Subscribe-time-only resolution is exactly what DNS rebinding defeats (public IP at validate time, private IP at delivery time). - -**Code changes:** -- Move IP validation into the delivery path. -- Use a resolve-then-connect-to-IP pattern (most HTTP clients support a custom resolver or socket-level connect with `Host`/SNI override). - ---- - -## 7. `X-MCP-Timestamp` header — SHOULD → MUST - -**Was:** Header was SHOULD; signature formula required it anyway. - -**Now:** Header is MUST. Signature formula unchanged: `HMAC-SHA256(secret, timestamp + "." + body)`. - -**Code changes:** -- Always send the header. Receiver: reject deliveries missing it. - ---- - -## 8. `StreamEventsResult` — only sent when the server can write a final frame - -**Was:** "Server MUST send `StreamEventsResult`" on cancellation, unconditionally. - -**Now:** -- stdio: server MUST send `StreamEventsResult` when the stream ends (either side). -- Streamable HTTP, **server-initiated** close: send `StreamEventsResult` as the final SSE `data:` frame. -- Streamable HTTP, **client-initiated** close (connection drop): no result is sent (physically can't). - -**Code changes:** -- HTTP handler: on client disconnect, just clean up — don't attempt to write the result. On server-initiated shutdown, write result then close. -- stdio handler: unchanged. - ---- - -## 9. Webhook `CursorExpired` and `terminated` — now POSTed to the endpoint - -**Was:** These error paths were specified for poll and push only; webhook was silent. - -**Now:** When a webhook subscription hits `CursorExpired` (`-32014`) or is terminated (e.g., `-32012 Unauthorized`), the server POSTs a signed error envelope to the callback URL: - -``` -POST -X-MCP-Signature: sha256=<...> -X-MCP-Timestamp: - -{"id":"","error":{"code":-32014,"message":"CursorExpired","data":{"reason":"..."}}} -``` - -It also surfaces the error in `deliveryStatus.lastError` on the next refresh response. - -**Code changes:** -- Add an error-delivery path that builds this envelope, signs it like a normal delivery, and POSTs it. -- Endpoint/receiver: handle a body with top-level `error` instead of `data`. - ---- - -## 10. Endpoint forwarding requirement — `cursor` and `eventId` MUST reach the client - -New normative requirement on the webhook **endpoint** (the receiver you POST to): it MUST make `cursor` and `eventId` available to the consuming client by whatever channel it forwards events. - -**Code changes (endpoint/relay implementations):** ensure both fields are passed through, not dropped. - ---- - -## 11. "No subscription state" wording softened — no behavior change - -The doc now says poll holds "no **protocol-required** subscription state" and explicitly permits the SDK to hold ephemeral derived state (lease table, emit ring buffer). If your SDK already holds these, you're now explicitly compliant. **No code change** — documentation alignment only. - ---- - -## 12. At-least-once guarantee qualified — no behavior change - -The guarantee now reads: at-least-once **when the cursor is backed by a durable upstream**; emit-only event types are **at-most-once across server restarts**. If you advertised unconditional at-least-once anywhere (docs, capability metadata), qualify it. **No runtime code change.** - ---- - -## 13. Push-mode reconnect backlog — acknowledged as a v1 gap - -Flow Control section now notes that push reconnect with a stale cursor can dump a large backlog with no protocol-level bound (poll has `maxEvents`/`hasMore`; push does not). Left to TCP backpressure / server-side pacing for v1. - -**Code changes:** none required. Optionally, add server-side pacing on backlog replay. - ---- - -## 14. `eventId` is server-assignable from upstream identifiers **(SDK API)** - -**Was:** Unstated; SDKs typically auto-generated `eventId`. - -**Now:** Spec clarifies `eventId` SHOULD be the upstream's stable event identifier (Stripe `evt_*`, GitHub delivery GUID, Kafka offset, Gmail message ID) when one exists. SDK auto-generates only when the author supplies none. - -**Why:** Dual-path sources (e.g., Stripe webhook emit + poll backfill) otherwise surface the same upstream event with two different SDK-generated IDs, breaking client dedup. - -**Code changes:** -- `Event` constructor / `emit()` / `check()` return: accept an optional `eventId` from the author. Only generate a UUID when it's omitted. - ---- - -## 15. Wire-format corrections **(breaking on the wire)** - -| Item | Was | Now | Action | -|---|---|---|---| -| Event notification method | `notifications/event` (in one example) | `notifications/events/event` everywhere | Rename the method constant if you copied from the JSON example | -| `nextPollSeconds` placement | Top-level in one diagram | Per-result entry only | Ensure your `PollEventsResult` has it inside each `results[i]`, not at top level | -| Error codes | `-32001..-32006` | `-32011..-32016` (shifted to avoid collision with base MCP `-32002 ResourceNotFound`); `CursorExpired` (`-32014`) now listed in the table | Renumber your error-code enum: `EventNotFound -32011`, `Unauthorized -32012`, `TooManySubscriptions -32013`, `CursorExpired -32014`, `InvalidCallbackUrl -32015`, `SubscriptionNotFound -32016` | -| `notifications/events/heartbeat` | Named, shape undefined | `{"jsonrpc":"2.0","method":"notifications/events/heartbeat","params":{}}` | Emit with empty `params` object | - ---- - -## 16. `events/unsubscribe` requires `delivery.url` **(breaking on the wire)** - -Consequence of #2. `delivery.url` is now part of the subscription key in all scopes, so `events/unsubscribe` MUST include it alongside `id`. Previously it was optional for authenticated callers. - -**Code changes:** Add `delivery: {url}` to the unsubscribe request schema (required). Server forms the full key `(principal?, delivery.url, id)`. If you keep a Redis-style store, key it on `sub:{principal}:{urlHash}:{id}`. - ---- - -## 17. Webhook `name`/`params` are immutable **(breaking)** - -`name` and `params` join `delivery.url` as immutable identity fields. A refresh that supplies different values addresses a different subscription (treated as create). To change what a subscription listens for: unsubscribe + resubscribe. - -**Why:** Mutating them in place left the upstream listener (provisioned by `on_subscribe`) bound to old params, silently delivering nothing. - -**Code changes:** Remove the "replace `name`/`params` on existing key" branch. Only `delivery.secret`, `cursor`, TTL, and `active` are mutable on refresh. - ---- - -## 18. Webhook refresh behavior — always pass cursor; refresh reactivates delivery - -Two clarifications to #1: -- Clients pass their **last-persisted cursor on every refresh** (not `null`). Server is idempotent: if the sub is live and the cursor is at/behind in-flight position, no-op; if lapsed/restarted, replay from there. -- A successful refresh **sets `active: true`**. If delivery was suspended after repeated failures, refresh resumes it. - -**Code changes:** Client SDK refresh loop sends persisted cursor, not `null`. Server: on refresh, clear the suspended flag and resume retry of pending events. - ---- - -## 19. Unified error/terminated shape across modes **(wire change)** - -`notifications/events/error` (push) now nests under `params.error` instead of flat `params.code`/`params.message`: -``` -{"method":"notifications/events/error","params":{"id":"...","error":{"code":-32011,"message":"..."}}} -``` -`notifications/events/terminated` is now defined with the same shape (`{id, error: {code, message, data?}}`). Poll error entries and webhook error POSTs already use this nested shape. - -**Code changes:** Update push error notification builder. Add `notifications/events/terminated` to your push message types. - ---- - -## 20. Webhook delivery headers + signature encoding **(wire change)** - -- Add `X-MCP-Subscription-Id: ` header so receivers select the secret before parsing the body. -- Signature header is exactly `X-MCP-Signature: sha256=`. -- Each retry regenerates `X-MCP-Timestamp` and the signature. - -**Code changes:** Add the header; lowercase-hex encode the HMAC; move timestamp+sign into the per-attempt path. - ---- - -## 21. SSRF hardening — `fe80::/10` + no redirects **(security)** - -Extends #6. Add `fe80::/10` (IPv6 link-local) to the blocklist. Webhook delivery requests MUST NOT follow HTTP redirects. - ---- - -## 22. `match`/`transform` signature + scope **(SDK API change)** - -Extends #5. Signatures are now `match(ctx, event, params)` / `transform(ctx, event, params)` (`ctx` carries principal + request metadata). The SDK applies these hooks not only on broadcast emit but also when an `events/poll` request reads from the emit-only ring buffer. - ---- - -## 23. Capability object — `listChanged` instead of `subscribe` **(wire change)** - -Events capability is `{"events": {"listChanged": true}}`. Delivery modes are advertised per-event in `deliveryModes`, not at the capability level. - ---- - -## 24. Cancellation wording - -HTTP cancellation = abort request stream (TCP close on HTTP/1.1, `RST_STREAM` on HTTP/2). stdio `StreamEventsResult` after cancel is now SHOULD (was MUST in #8's first pass) to align with base MCP's SHOULD-NOT-respond-after-cancel. - ---- - -## Quick triage - -If you need to prioritize: - -1. **Security:** #6 (DNS rebinding), #7 (timestamp MUST), #21 (`fe80::/10`, no redirects) -2. **Wire-breaking:** #1, #2, #3, #15, #16 (unsubscribe needs url), #17 (name/params immutable), #19 (error shape), #20 (headers/encoding), #23 (capability) -3. **SDK-internal breaking:** #4 (lease key), #5/#22 (`match`/`transform` hooks + ctx), #14 (`eventId` passthrough), #18 (refresh cursor/reactivate) -4. **Additive:** #8, #9, #10 -5. **Docs-only/minor:** #11, #12, #13, #24 From 249efdcc63826eac4856e76faa78ae9633f95ce8 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Thu, 30 Apr 2026 14:30:53 +0100 Subject: [PATCH 05/25] Revise Events design sketch: poll mandatory, webhook delivery profile, SEP review fixes 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. --- docs/design-sketch-proposal.md | 274 +++++++++++++++------------------ 1 file changed, 124 insertions(+), 150 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index 583bff3..35f53ec 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -10,7 +10,7 @@ Add an Events primitive to MCP that allows clients to subscribe to things happen Key design principles: -- **Servers advertise delivery modes; no mode is mandatory.** Each event type lists the delivery modes it supports. The client picks the best mode it can use. If there is no overlap between what the server offers and what the client can consume, that event type is simply unavailable to that client — the protocol does not require a universal fallback. (Reference SDKs are still encouraged to make poll cheap to support so that overlap is common in practice.) +- **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. @@ -88,7 +88,7 @@ Servers advertise event support in their capabilities: **Notes:** -- `delivery` lists the delivery modes this event type supports — any non-empty subset of `"poll"`, `"push"`, `"webhook"`. No mode is mandatory. A client that cannot use any of the listed modes cannot subscribe to this event type. +- `delivery` lists 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. - `inputSchema` is 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 the `inputSchema` on tools for consistency. - `payloadSchema` describes the shape of `data` in delivered events. @@ -101,7 +101,7 @@ If the set of available event types changes at runtime (e.g., a plugin is loaded There are three delivery modes with different subscription mechanisms: - **Poll mode:** Client calls `events/poll` with 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 POST (`events/stream`) carrying all desired subscriptions. Events are delivered on the SSE response stream (HTTP) or as notifications on stdout (stdio). Connection close terminates all subscriptions. Server state is scoped to connection lifetime. +- **Push mode:** Client opens a long-lived `events/stream` request 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/subscribe` to 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-calling `events/subscribe` before 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 @@ -115,8 +115,9 @@ There are three delivery modes with different subscription mechanisms: | `-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..-32016` to avoid collision with base MCP error codes (e.g., `-32002 ResourceNotFound`). +These codes occupy `-32011..-32017` to avoid collision with base MCP error codes (e.g., `-32002 ResourceNotFound`). ### Poll-Based Delivery @@ -129,34 +130,26 @@ sequenceDiagram participant Server as MCP Server loop every nextPollSeconds - SDK->>Server: events/poll {subscriptions[], cursors} - Server-->>SDK: {results: [{events[], cursor, nextPollSeconds}]} + SDK->>Server: events/poll {id, name, params, cursor} + Server-->>SDK: {events[], cursor, nextPollSeconds, hasMore} end Note over SDK: LLM invoked only when
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. + ```jsonc { - "maxEvents": 50, // optional; cap events per subscription - "subscriptions": [ - { - "id": "sub_email", // client-provided identifier - "name": "email.received", - "params": { - "from": "*@anthropic.com", - "redact_pii": true - }, - "cursor": null // null = start from now - }, - { - "id": "sub_incidents", - "name": "incident.created", - "params": { "severity": "P1" }, - "cursor": "cursor_abc" // resume from previous position - } - ] + "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 } ``` @@ -164,75 +157,53 @@ sequenceDiagram ```jsonc { - "results": [ + "id": "sub_email", + "events": [ { - "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 - }, - { - "id": "sub_incidents", - "events": [], - "cursor": "cursor_abc", - "hasMore": false, - "nextPollSeconds": 60 + "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:** -- `id` is a client-provided identifier for each subscription. It is opaque to the server and echoed back in responses to allow the client to correlate results with subscriptions. It must be unique within a single `events/poll` request. +- `id` is 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 same `id` for several subscriptions to fan results into a single handler. - `cursor` is opaque to the client. The client stores it and passes it back on the next poll. A `null` cursor means "start from now" — the server returns no events and provides a fresh cursor for subsequent polls. - `eventId` enables client-side deduplication across polls (e.g., after a crash/restart). It is **server-assigned**: when the upstream source provides a stable event identifier (Stripe `evt_*`, GitHub delivery GUID, Kafka offset, Gmail message ID), the server SHOULD use that value as `eventId` so that the same upstream event surfaced via multiple paths (e.g., webhook emit and poll backfill) carries the same `eventId` and dedup works. The SDK auto-generates an `eventId` only when the author supplies none. -- `maxEvents` is an optional top-level field that caps the number of events returned per subscription. If more events are available than the limit, the server returns a partial batch with an intermediate cursor and sets `hasMore: true`. The client SHOULD poll again immediately (ignoring `nextPollSeconds`) to drain the backlog. If omitted, the server uses its own default limit. +- `maxEvents` is 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 sets `hasMore: true`. The client SHOULD poll again immediately (ignoring `nextPollSeconds`) to drain the backlog. If omitted, the server uses its own default limit. - `hasMore` indicates whether additional events are available beyond the returned batch. When `true`, the client should poll again immediately with the updated cursor. When `false`, the client waits `nextPollSeconds` before the next poll. -- `nextPollSeconds` allows the server to dynamically adjust polling frequency per subscription (e.g., back off when rate-limited upstream, speed up when activity is detected). Ignored when `hasMore` is `true`. +- `nextPollSeconds` allows the server to dynamically adjust polling frequency (e.g., back off when rate-limited upstream, speed up when activity is detected). Ignored when `hasMore` is `true`. Clients SHOULD apply a configurable floor (default 1 second) to guard against a misbehaving server inducing a tight loop. - Empty `events` array 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.) - -#### Error Handling - -If an individual subscription within a poll request is invalid, the server returns an error for that subscription without failing the entire request: - -```jsonc -{ - "results": [ - { - "id": "sub_email", - "events": [ "..." ], - "cursor": "historyId_99842", - "nextPollSeconds": 30 - }, - { - "id": "sub_bogus", - "error": { - "code": -32011, - "message": "EventNotFound" - } - } - ] -} -``` +- 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. The client sends all desired subscriptions, 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`. +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: @@ -245,11 +216,11 @@ sequenceDiagram participant SDK as Client SDK participant Server as MCP Server - SDK->>Server: events/stream {id, subscriptions[]} + SDK->>Server: events/stream {id, name, params, cursor} activate Server - Server-->>SDK: notifications/events/active {id, cursor} ×N + Server-->>SDK: notifications/events/active {requestId, id, cursor} loop as events occur - Server-->>SDK: notifications/events/event {id, event, cursor} + Server-->>SDK: notifications/events/event {requestId, id, event, cursor} end SDK->>Server: notifications/cancelled {requestId} (stdio)
— or connection close (Streamable HTTP) Server-->>SDK: StreamEventsResult (final frame) @@ -266,54 +237,45 @@ sequenceDiagram "method": "events/stream", "id": 1, "params": { - "subscriptions": [ - { - "id": "sub_email", - "name": "email.received", - "params": { "from": "*@anthropic.com", "redact_pii": true }, - "cursor": null - }, - { - "id": "sub_incidents", - "name": "incident.created", - "params": { "severity": "P1" }, - "cursor": "cursor_abc" - } - ] + "id": "sub_email", + "name": "email.received", + "params": { "from": "*@anthropic.com", "redact_pii": true }, + "cursor": null } } ``` #### Event Delivery -The server confirms each subscription, reports errors for invalid ones, and then delivers events as notifications. When the stream ends, the server sends a `StreamEventsResult` as the final frame: +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. ```jsonc -// Confirmation (one per valid subscription) -{"jsonrpc":"2.0","method":"notifications/events/active","params":{"id":"sub_email","cursor":"historyId_99840"}} -{"jsonrpc":"2.0","method":"notifications/events/active","params":{"id":"sub_incidents","cursor":"cursor_abc"}} - -// Error for invalid subscription (stream remains open for valid ones) -{"jsonrpc":"2.0","method":"notifications/events/error","params":{"id":"sub_bogus","error":{"code":-32011,"message":"EventNotFound"}}} +// 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":{"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"}} +{"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":{}}} ``` -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, and the result is sent when the stream ends. +`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 `StreamEventsResult` is 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 stdio (SHOULD), and on Streamable HTTP only when the server initiates the close. -- **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 a `notifications/events/heartbeat` notification with empty params: `{"jsonrpc":"2.0","method":"notifications/events/heartbeat","params":{}}`. The server SHOULD send a heartbeat at least every 30 seconds. The client SHOULD treat absence of any data (events or heartbeats) beyond a threshold (e.g., 60 seconds) as connection failure and reconnect with cursors. -- **Cancellation.** On Streamable HTTP, the client aborts the request stream. On stdio, the client sends `notifications/cancelled` with the `requestId` matching the `events/stream` request's `id`. In both cases, the server MUST stop delivering events and release any associated resources. On stdio, the server SHOULD then send the `StreamEventsResult` (the result is empty and harmless; base MCP says servers SHOULD NOT respond to cancelled requests, so a server that omits it is also compliant). On Streamable HTTP, the abort is the terminal signal and no result is sent. -- **Updating subscriptions.** A client MAY hold multiple concurrent `events/stream` requests open, each with its own subscription list. To add subscriptions, open an additional stream; to remove them, cancel only the stream that carries them. Nothing prevents a client from instead consolidating onto a single stream by cancelling and re-issuing `events/stream` with the full updated list — cursor replay covers the transition gap — but this is an optimization, not a requirement. Note that on HTTP/1.1 each stream consumes a TCP connection, so clients that expect many independent subscriptions effectively depend on HTTP/2 multiplexing for the multi-stream approach to scale; SDKs SHOULD coalesce subscriptions onto fewer streams when the transport does not multiplex. -- **Reconnection after failure.** If the connection drops (HTTP) or the server stops sending (stdio), the client sends a new `events/stream` with the same subscriptions and their last-known cursors. +- **Stream termination.** The `StreamEventsResult` is 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 to `events/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/cancelled` with the `requestId` matching the `events/stream` request's `id`. In both cases, the server MUST stop delivering events and release any associated resources. On stdio, the server MAY then send the `StreamEventsResult`; 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/stream` requests 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 via `requestId`. +- **Reconnection after failure.** If the connection drops (HTTP) or the server stops sending (stdio), the client sends a new `events/stream` with the same subscription and its last-known cursor. #### Cursor Advancement @@ -389,7 +351,7 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau - `events/subscribe` is ONLY used for webhook delivery. Poll and push do not need it. - `id` is 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. - `secret` is 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 fresh `secret` appears in the response — the client MUST check for its presence on every refresh and update the verifier accordingly. The client MAY supply `delivery.secret` in 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. `null` means "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 track a delivery watermark, and the response does not include a cursor. The client persists the `cursor` carried in each delivered payload (see *Webhook Event Delivery*) and 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. +- `cursor` (request only) tells the server where to begin delivery. `null` means "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 that `cursor` and 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. - `refreshBefore` is mandatory. It is an ISO 8601 timestamp indicating when the subscription will expire. The client MUST re-call `events/subscribe` with the same subscription key before this time to keep the subscription alive. The server resets the TTL on each refresh. - `events/subscribe` is 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*). @@ -429,7 +391,6 @@ The server POSTs events to the callback URL as they occur: ``` POST https://proxy.example.com/hooks/client123 Content-Type: application/json -X-MCP-Subscription-Id: f47ac10b-58cc-4372-a567-0e02b2c3d479 X-MCP-Signature: sha256= X-MCP-Timestamp: 1739980800 @@ -449,12 +410,12 @@ X-MCP-Timestamp: 1739980800 **Notes:** -- `X-MCP-Subscription-Id` carries the subscription `id` so the receiver can select the correct secret before parsing the body. `X-MCP-Signature` contains an HMAC-SHA256 signature (lowercase hex) computed over `timestamp + "." + body` using the shared secret from subscription. The receiver MUST verify this before processing. The `X-MCP-Timestamp` header 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. +- `X-MCP-Signature` contains an HMAC-SHA256 signature (lowercase hex) computed over `timestamp + "." + body` using the shared secret from subscription. The receiver MUST verify this before processing. The receiver reads `id` from 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 tampered `id` simply causes verification to fail. The `X-MCP-Timestamp` header 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. - `eventId` in the body enables idempotent processing. The receiver SHOULD deduplicate by this value. -- `cursor` in the body is the subscription's position *after* this event. The endpoint MUST make `cursor` and `eventId` available 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-`2xx` responses, and does not track a server-side cursor watermark. 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 uses `eventId` for deduplication and `timestamp` for ordering if needed. +- `cursor` in 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 include `cursor_N` in event N's payload until events at positions `< N` have 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 make `cursor` and `eventId` available 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-`2xx` responses. 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 uses `eventId` for deduplication and `timestamp` for ordering if needed. Because the payload `cursor` is 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 `2xx` response from the webhook endpoint signals that the event has been accepted and the server need not retry it. The endpoint SHOULD NOT return `2xx` until 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.** Because the server may begin delivering as soon as the subscription is persisted, the first webhook POST can arrive before the `events/subscribe` response (and thus the `secret`) reaches the client. The recommended handling for now is retry tolerance: a receiver that gets a delivery for an `id` it does not yet recognise, or that it cannot yet verify, SHOULD return a retryable status (e.g., `503` or `425 Too Early`) rather than dropping it; the server's normal retry/backoff machinery will redeliver once the client is ready. `eventId` deduplication and cursor replay make this safe. A two-phase activate handshake was considered and may be added later if this proves insufficient in practice. +- **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 `secret` from the `events/subscribe` response. A receiver that nonetheless gets a delivery for an `id` it does not yet recognise, or that it cannot yet verify, SHOULD return a retryable status (e.g., `503` or `425 Too Early`) rather than dropping it; the server's normal retry/backoff machinery will redeliver once the client is ready. `eventId` deduplication 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 (sets `active: true`) and the server resumes retrying pending events; if the client never refreshes, the subscription expires naturally at TTL. #### Webhook Delivery Status @@ -482,21 +443,34 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre "deliveryStatus": { "active": false, "lastDeliveryAt": "2026-02-19T15:45:00Z", - "lastError": "Webhook endpoint returned 403 Forbidden", + "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` provides a human-readable description of the most recent failure. The client can use this information to diagnose connectivity or authentication issues with the webhook endpoint. +`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 falls in private, loopback, or link-local address ranges (`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. +**SSRF prevention.** The server MUST validate callback URLs. Servers SHOULD reject URLs whose resolved IP is not globally routable per the [IANA IPv4](https://www.iana.org/assignments/iana-ipv4-special-registry/) and [IPv6](https://www.iana.org/assignments/iana-ipv6-special-registry/) 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-Subscription-Id` header (the subscription `id`, so the receiver can select the correct secret without parsing the body) and 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=` where the value is `HMAC-SHA256(secret, timestamp + "." + body)` encoded as lowercase hexadecimal. 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. +**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=` 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](https://www.standardwebhooks.com/); 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. @@ -506,6 +480,16 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre 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 `POST` only. +- **Content-Type:** `application/json`. +- **Required headers:** `X-MCP-Signature`, `X-MCP-Timestamp`, and `X-MCP-Subscription-Id` are 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 treat `413` as 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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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. @@ -540,30 +524,22 @@ Cursors are opaque strings managed by the server. They represent a position in t **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 appears in the per-subscription results: +For poll mode, this is a standard JSON-RPC error response to the `events/poll` request: ```jsonc -{ - "id": "sub_email", - "error": { - "code": -32014, - "message": "CursorExpired", - "data": { "reason": "Upstream history compacted" } - } -} +{"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":{"id":"sub_email","error":{"code":-32014,"message":"CursorExpired","data":{"reason":"Upstream history compacted"}}}} +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-Subscription-Id: f47ac10b-58cc-4372-a567-0e02b2c3d479 X-MCP-Signature: sha256=<...> X-MCP-Timestamp: 1739980800 @@ -578,11 +554,11 @@ The server SDK should make implementing events as simple as implementing tools. 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 available by default — 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). Authors MAY opt an event type out of poll (e.g., when buffering is undesirable for a high-volume push-only source). +- **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_ttl` configured. -The SDK computes the `delivery` array in `events/list` responses from the above, minus any modes the author has explicitly disabled. The protocol does not require any particular mode to be present, so the SDK MUST also accept an explicit `delivery=[...]` override per event type for authors who want full control. 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. +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. ```python @server.event( @@ -611,7 +587,7 @@ async def check_email(context, params, cursor): 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 each subscription in an incoming `events/poll` request and returns the results. The server is stateless. +**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: @@ -712,7 +688,7 @@ async def on_unsubscribe(context, params, subscription_id): 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. +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: @@ -761,9 +737,9 @@ The SDK intersects the event type's advertised `delivery` list with the modes th 1. **Webhook** if the client has `WebhookConfig` set and the server lists it — low latency without tying up a persistent connection. 2. **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. -3. **Poll** if the server lists it — no server state, no persistent connection. +3. **Poll** — always available; no server state, no persistent connection. -If the intersection is empty, `client.subscribe()` raises `NoCompatibleDeliveryMode`. The protocol does not guarantee a universal fallback, so client SDKs MUST surface this as an error rather than silently degrading. +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: @@ -780,9 +756,9 @@ sub = await client.subscribe("incident.created", params={...}, delivery="webhook ### How Each Mode Works -**Poll mode:** The SDK runs a background task that calls `events/poll` at the server-recommended interval, batching all subscriptions to the same server into a single request. Events are yielded to the subscription's async iterator. The caller does not implement or manage the polling loop. +**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 sends an `events/stream` request and listens for notifications. When subscriptions are added or removed, the SDK either opens an additional stream for the delta or — when the transport doesn't multiplex well (HTTP/1.1) — cancels the current stream and re-issues `events/stream` with the consolidated list and current cursors. Cursor-based replay covers any transition gap. +**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. @@ -817,18 +793,18 @@ This reduces PII exposure in event infrastructure (logs, queues, buffers) and li | Mode | Transport | |---|---| | Push | `notifications/events/terminated` on the SSE stream | -| Poll | Error entry in the poll response for that subscription | +| 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":{"id":"sub_slack","error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}}}} +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 `{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. +`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. @@ -838,7 +814,7 @@ The spec does not mandate specific governance mechanisms but is designed to enab - **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, close the connection (HTTP) or send `notifications/cancelled` (stdio). For webhook mode, call `events/unsubscribe` for immediate termination, or simply stop refreshing and the subscription will expire at TTL. +- **Kill switch:** For poll mode, stop polling. For push mode, abort the request stream (HTTP) or send `notifications/cancelled` (stdio). For webhook mode, call `events/unsubscribe` for immediate termination, or simply stop refreshing and the subscription will expire at TTL. - **Rate limiting:** `nextPollSeconds` allows servers to control polling frequency dynamically. Clients SHOULD respect it. Enterprise deployments may impose additional rate limits. ## Relationship to Existing Primitives @@ -866,15 +842,13 @@ The spec does not mandate specific governance mechanisms but is designed to enab 2. **Should resource subscriptions and `list_changed` notifications be re-expressed as events?** The existing `resources/subscribe` → `notifications/resources/updated` flow and the `notifications/{tools,resources,prompts}/list_changed` family are effectively a special-cased push-only event channel. Folding them into this primitive (e.g., a reserved `mcp.resource.updated` event type whose params are `{uri}`, and `mcp.{tools,resources,prompts}.list_changed` event 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 an `mcp.*` event-name prefix at all. -3. **How should servers publish their egress IP ranges for webhook delivery?** Enterprises deploying webhook mode will typically need to allowlist the source IPs that webhook requests originate from. Should the spec define a discovery mechanism (e.g., a well-known endpoint like `/.well-known/mcp-egress-ips`, or a field in the server's capabilities/metadata)? Or leave it to out-of-band documentation per server? A standard mechanism would simplify enterprise onboarding, but adds burden on server operators who may use dynamic egress (cloud NAT, serverless) where stable IP ranges are hard to guarantee. +3. **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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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. -4. **Should webhook delivery adopt the Standard Webhooks format instead of defining its own?** [Standard Webhooks](https://www.standardwebhooks.com/) specifies a delivery envelope very close to what this doc defines: `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers, `HMAC-SHA256(secret, id + "." + timestamp + "." + body)` with a `whsec_` prefix on secrets, base64 `v1,` 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 5 below for free. The cost is losing the `X-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 subscription `id` carried as `webhook-id`. +4. **Should webhook delivery adopt the Standard Webhooks format instead of defining its own?** [Standard Webhooks](https://www.standardwebhooks.com/) specifies a delivery envelope very close to what this doc defines: `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers, `HMAC-SHA256(secret, id + "." + timestamp + "." + body)` with a `whsec_` prefix on secrets, base64 `v1,` 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 the `X-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 subscription `id` carried as `webhook-id`. -5. **Can a single subscription span multiple event names with one cursor?** 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_changed` and `k8s.oom_killed` opens 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's `name` to 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. +5. **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_changed` and `k8s.oom_killed` opens 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's `name` to 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. -6. **Should webhook subscription require an ownership-verification handshake?** Before activating delivery, the server would POST a challenge token to `delivery.url` and require the endpoint to echo it back (cf. Slack's URL verification, SNS `SubscriptionConfirmation`). This proves the subscriber controls the endpoint, preventing a client from pointing deliveries at a third party. Cost: an extra round-trip and an endpoint-side requirement. The current SSRF defenses (blocklist, no-redirect, delivery-time IP validation) mitigate internal-target abuse but not third-party-target abuse. - -7. **Should webhook deliveries support multiple signatures for zero-downtime secret rotation?** The current design has a single `X-MCP-Signature` header, and rotation happens via an atomic upsert of `delivery.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=,v1=,v1=`) 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 allow `delivery.secret` to be an array (or require servers to dual-sign for N seconds after rotation)? +6. **Should webhook deliveries support multiple signatures for zero-downtime secret rotation?** *(wire-breaking if adopted)* The current design has a single `X-MCP-Signature` header, and rotation happens via an atomic upsert of `delivery.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=,v1=,v1=`) 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 allow `delivery.secret` to be an array (or require servers to dual-sign for N seconds after rotation)? ## Note on Consistency and Ordering @@ -886,7 +860,7 @@ What the protocol *does* provide: - **Cursors** for resumability — the client can pick up where it left off after a disconnect or crash. - **`eventId`** for 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 and the server does not track a delivery watermark, so concurrent requests and retries can reorder arrivals (this matches Stripe, GitHub, Shopify, and the Standard Webhooks spec). Clients that need ordering use the `timestamp` field in event payloads. No ordering is guaranteed across subscriptions in any mode. +- **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 `timestamp` field 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). @@ -927,7 +901,7 @@ sequenceDiagram SDK->>Server: events/subscribe
{id, name: "pull_request.opened", params: {repo: "acme/webapp"},
delivery: {mode: "webhook", url: "https://proxy/.../hooks"}} Server->>Redis: SETEX sub:{principal}:{urlHash}:{id} {ttl}
{name, params, url, secret} Server->>GH: ensure repo webhook registered
(server-internal, idempotent) - Server-->>SDK: {secret, cursor, refreshBefore} + Server-->>SDK: {secret, refreshBefore} end rect rgb(245,250,245) From 26ba242ff0d29e96ba8f4b64ce9acb32162c66f6 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Thu, 30 Apr 2026 15:02:32 +0100 Subject: [PATCH 06/25] Revise Events design sketch: Standard Webhooks, client-supplied secret, tuple-based identity - Adopt Standard Webhooks as the delivery signature scheme (webhook-id/timestamp/signature, v1 base64, multi-sig rotation); keep X-MCP-Subscription-Id as the one MCP-specific header - Webhook secret is now client-supplied and REQUIRED (whsec_ + 24-64B base64, server MUST reject otherwise); server never generates one - Subscription identity is (principal?, delivery.url, name, params); the response returns a server-derived id used only for routing, never as input - Revert to no-mode-mandatory for delivery modes - TLS is now MUST for delivery.url - Allow cursor: null in any delivery for event types without replay; clarify CursorExpired always implies a possible gap --- docs/design-sketch-proposal.md | 133 ++++++++++++++++----------------- 1 file changed, 66 insertions(+), 67 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index 35f53ec..598bc64 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -10,7 +10,7 @@ Add an Events primitive to MCP that allows clients to subscribe to things happen 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. +- **Servers advertise delivery modes; no mode is mandatory.** Each event type lists the delivery modes it supports. The client picks the best mode it can use. If there is no overlap between what the server offers and what the client can consume, that event type is simply unavailable to that client — the protocol does not require a universal fallback. (Reference SDKs are still encouraged to make poll cheap to support so that overlap is common in practice.) - **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. @@ -88,7 +88,7 @@ Servers advertise event support in their capabilities: **Notes:** -- `delivery` lists 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. +- `delivery` lists the delivery modes this event type supports — any non-empty subset of `"poll"`, `"push"`, `"webhook"`. No mode is mandatory. A client that cannot use any of the listed modes cannot subscribe to this event type. - `inputSchema` is 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 the `inputSchema` on tools for consistency. - `payloadSchema` describes the shape of `data` in delivered events. @@ -114,7 +114,7 @@ There are three delivery modes with different subscription mechanisms: | `-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) | +| `-32016` | `SubscriptionNotFound` | No subscription matches the supplied key 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`). @@ -187,12 +187,12 @@ Each entry in `events[]` (and the body of push/webhook deliveries) is an `EventO | `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) | +| `cursor` | string \| null | no | Subscription position after this event (push/webhook only; poll carries cursor at the response level). `null` when the event type does not support replay — see *Cursor Lifecycle*. | **Notes:** - `id` is 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 same `id` for several subscriptions to fan results into a single handler. -- `cursor` is opaque to the client. The client stores it and passes it back on the next poll. A `null` cursor means "start from now" — the server returns no events and provides a fresh cursor for subsequent polls. +- `cursor` is opaque to the client. The client stores it and passes it back on the next poll. In the request, `null` means "start from now" — the server returns no events and provides a fresh cursor for subsequent polls. In the response, the server MAY return `cursor: null` for an event type that does not support replay; the client then has nothing to persist and always polls with `null` (see *Cursor Lifecycle*). - `eventId` enables client-side deduplication across polls (e.g., after a crash/restart). It is **server-assigned**: when the upstream source provides a stable event identifier (Stripe `evt_*`, GitHub delivery GUID, Kafka offset, Gmail message ID), the server SHOULD use that value as `eventId` so that the same upstream event surfaced via multiple paths (e.g., webhook emit and poll backfill) carries the same `eventId` and dedup works. The SDK auto-generates an `eventId` only when the author supplies none. - `maxEvents` is 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 sets `hasMore: true`. The client SHOULD poll again immediately (ignoring `nextPollSeconds`) to drain the backlog. If omitted, the server uses its own default limit. - `hasMore` indicates whether additional events are available beyond the returned batch. When `true`, the client should poll again immediately with the updated cursor. When `false`, the client waits `nextPollSeconds` before the next poll. @@ -279,7 +279,7 @@ The `events/stream` response carries only `notifications/events/*` messages. Non #### 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. +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. The server MAY send `cursor: null` for event types that do not support replay (see *Cursor Lifecycle*). ### Webhook-Based Delivery @@ -298,8 +298,8 @@ sequenceDiagram participant Server as MCP Server participant Hook as Webhook Endpoint - SDK->>Server: events/subscribe {id, name, params, delivery: {url}, cursor} - Server-->>SDK: {secret, refreshBefore} + SDK->>Server: events/subscribe {name, params, delivery: {url, secret}, cursor} + Server-->>SDK: {id, refreshBefore} loop as events occur Server->>Hook: POST {id, event, cursor} + HMAC signature Hook-->>Server: 200 OK @@ -310,7 +310,7 @@ sequenceDiagram Server-->>SDK: {refreshBefore'} end opt explicit teardown (else: stop refreshing → TTL expiry) - SDK->>Server: events/unsubscribe {id, delivery: {url}} + SDK->>Server: events/unsubscribe {name, params, delivery: {url}} Server-->>SDK: (ack) end ``` @@ -325,12 +325,12 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau "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" + "url": "https://proxy.example.com/hooks/client123", + "secret": "whsec_NWNmOGE3YjJkNGU2ZjgwMTIzNDU2Nzg5YWJjZGVmMDE" }, "cursor": null } @@ -340,8 +340,7 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau ```jsonc // Response { - "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "secret": "whsec_5c8f...", // present when subscription is (re)created + "id": "sub_a3f1c8e2b0d49f7e", // server-derived; stable for this (principal?, url, name, params) "refreshBefore": "2026-02-19T16:30:00Z" } ``` @@ -349,40 +348,33 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau **Notes:** - `events/subscribe` is ONLY used for webhook delivery. Poll and push do not need it. -- `id` is 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. -- `secret` is 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 fresh `secret` appears in the response — the client MUST check for its presence on every refresh and update the verifier accordingly. The client MAY supply `delivery.secret` in 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. +- `delivery.secret` is REQUIRED. The client supplies the HMAC signing secret; the server never generates one. The value MUST be a Standard Webhooks symmetric secret: the literal prefix `whsec_` followed by base64 of 24–64 random bytes. Servers MUST reject values that do not satisfy this with `InvalidParams`. Client SDKs SHOULD generate the secret on the application's behalf rather than expose an interface that encourages hand-picked values. +- `id` (response only) is a server-derived handle for the subscription, deterministic over `(principal?, delivery.url, name, params)`. The server returns it so the receiver can route deliveries (it appears in the `X-MCP-Subscription-Id` header on every POST). It is stable across refreshes and server restarts. The client does not generate or send it. - `cursor` (request only) tells the server where to begin delivery. `null` means "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 that `cursor` and 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. - `refreshBefore` is mandatory. It is an ISO 8601 timestamp indicating when the subscription will expire. The client MUST re-call `events/subscribe` with the same subscription key before this time to keep the subscription alive. The server resets the TTL on each refresh. - `events/subscribe` is 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*). +- The server holds subscription state (event name, params, callback URL, secret, derived id) 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: +**Key composition.** The subscription key is `(principal?, delivery.url, name, params)`, where `principal` is the server's canonical identifier for the authenticated subject (e.g., OAuth `sub`, API key ID, service-account name) and is included when present. `params` is compared by canonical-JSON equality. There is no client-generated `id` — a subscription is fully determined by what it listens for, where it delivers, and (when authenticated) who asked. -- On servers that authenticate the caller: `(principal, delivery.url, id)`. -- On servers without caller authentication: `(delivery.url, id)`. +All four components are immutable for the subscription's lifetime: a subscribe call with a different value for any of them addresses a different subscription. To change what a subscription listens for or where it delivers, `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. -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. +**Derived `id`.** The server computes a deterministic `id` over the key (e.g., a truncated SHA-256 of the canonical key serialization) and returns it in the subscribe response. This is a routing handle, not a capability: it appears in `X-MCP-Subscription-Id` on every delivery and MAY be logged. Knowing it does not authorize anything. 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. | +| `delivery.secret` | Replaced. To rotate, the client supplies a new value on refresh; the server SHOULD dual-sign (Standard Webhooks multi-signature) with old and new for a short grace window so in-flight deliveries verify under either. | | `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. +**Cross-tenant isolation.** Because the key includes `delivery.url` (and `principal` when authenticated), two distinct tenants subscribing to the same `(name, params)` get distinct subscriptions. A caller who learns another tenant's derived `id` gains nothing — `id` is not accepted as input to any method. #### Webhook Event Delivery @@ -391,11 +383,13 @@ 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= -X-MCP-Timestamp: 1739980800 +webhook-id: evt_789 +webhook-timestamp: 1739980800 +webhook-signature: v1, +X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e { - "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "id": "sub_a3f1c8e2b0d49f7e", "eventId": "evt_789", "name": "incident.created", "timestamp": "2026-02-19T16:00:00Z", @@ -410,12 +404,12 @@ X-MCP-Timestamp: 1739980800 **Notes:** -- `X-MCP-Signature` contains an HMAC-SHA256 signature (lowercase hex) computed over `timestamp + "." + body` using the shared secret from subscription. The receiver MUST verify this before processing. The receiver reads `id` from 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 tampered `id` simply causes verification to fail. The `X-MCP-Timestamp` header 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. +- Deliveries follow the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) signature scheme. `webhook-id` carries the `eventId` (per-delivery, used for dedup). `webhook-timestamp` is the Unix timestamp (seconds) of the request. `webhook-signature` is `v1,` where the value is `HMAC-SHA256(secret, webhook-id + "." + webhook-timestamp + "." + body)`; multiple space-delimited signatures MAY be present during secret rotation. `X-MCP-Subscription-Id` is an MCP-specific header (not part of Standard Webhooks) carrying the subscription `id` so the receiver can select the correct secret before parsing the body. The receiver MUST verify the signature before processing, SHOULD reject deliveries where `webhook-timestamp` is more than 5 minutes old, and SHOULD deduplicate on `webhook-id`. Each retry attempt regenerates the timestamp and signature. - `eventId` in the body enables idempotent processing. The receiver SHOULD deduplicate by this value. -- `cursor` in 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 include `cursor_N` in event N's payload until events at positions `< N` have 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 make `cursor` and `eventId` available 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. +- `cursor` in 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 include `cursor_N` in event N's payload until events at positions `< N` have 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 server MAY send `cursor: null` for event types that do not support replay (see *Cursor Lifecycle*); in that case there is no recovery point to persist. The endpoint MUST make `cursor` and `eventId` available 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-`2xx` responses. 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 uses `eventId` for deduplication and `timestamp` for ordering if needed. Because the payload `cursor` is 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 `2xx` response from the webhook endpoint signals that the event has been accepted and the server need not retry it. The endpoint SHOULD NOT return `2xx` until 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 `secret` from the `events/subscribe` response. A receiver that nonetheless gets a delivery for an `id` it does not yet recognise, or that it cannot yet verify, SHOULD return a retryable status (e.g., `503` or `425 Too Early`) rather than dropping it; the server's normal retry/backoff machinery will redeliver once the client is ready. `eventId` deduplication and cursor replay make this safe. +- **Subscribe/delivery race.** Because the secret is client-supplied, the receiver can verify the very first delivery — there is no window where a delivery arrives before the receiver knows the secret. A receiver that gets a delivery for an `id` it has not yet been told to route (e.g., the subscribe response has not propagated to the gateway) SHOULD return a retryable status (`503` or `425 Too Early`); the server's retry/backoff redelivers once the receiver is ready. `eventId` deduplication 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 (sets `active: true`) and the server resumes retrying pending events; if the client never refreshes, the subscription expires naturally at TTL. #### Webhook Delivery Status @@ -425,7 +419,7 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre ```jsonc // Healthy subscription refresh { - "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "id": "sub_a3f1c8e2b0d49f7e", "refreshBefore": "2026-02-19T17:00:00Z", "deliveryStatus": { "active": true, @@ -438,7 +432,7 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre ```jsonc // Subscription with delivery failures { - "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "id": "sub_a3f1c8e2b0d49f7e", "refreshBefore": "2026-02-19T17:00:00Z", "deliveryStatus": { "active": false, @@ -460,21 +454,25 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre ``` POST https://proxy.example.com/hooks/client123 Content-Type: application/json -X-MCP-Signature: sha256=<...> -X-MCP-Timestamp: 1739980800 +webhook-id: msg_verify_9f86d081 +webhook-timestamp: 1739980800 +webhook-signature: v1,<...> +X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e -{"type":"verification","id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","challenge":"9f86d081884c7d65"} +{"type":"verification","id":"sub_a3f1c8e2b0d49f7e","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. +**TLS requirement.** Callback URLs MUST use `https://`. Servers MUST reject `events/subscribe` with a non-`https` `delivery.url` (`-32015 InvalidCallbackUrl`). Event payloads and the `delivery.secret` (sent in the subscribe request) would otherwise transit in cleartext. -**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=` 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](https://www.standardwebhooks.com/); see Open Question 4 on whether to adopt its header names directly. +**Signature scheme (Standard Webhooks profile).** MCP webhook delivery is a profile of [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md). Every delivery MUST include `webhook-id` (the `eventId`), `webhook-timestamp` (Unix seconds), and `webhook-signature`. The signature is `HMAC-SHA256(secret, webhook-id + "." + webhook-timestamp + "." + body)` encoded as base64 with a `v1,` prefix. In this formula, `body` is the raw HTTP request body bytes exactly as received, before any parsing or re-serialization; the `webhook-id + "." + webhook-timestamp + "."` prefix is UTF-8; and `secret` is the base64-decoded bytes of the value after the `whsec_` prefix. Receivers MUST compute the HMAC over the raw body, never over a re-serialized JSON object. The header MAY contain multiple space-delimited signatures (`v1, v1,`) during secret rotation; the receiver accepts if any verifies. Each retry attempt MUST regenerate the timestamp and signature so retries are not rejected by the receiver's freshness window. The receiver SHOULD reject deliveries where `webhook-timestamp` is more than 5 minutes old. Off-the-shelf Standard Webhooks verifiers (e.g., Svix libraries) work without modification. -**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. +In addition to the Standard Webhooks headers, deliveries MUST include `X-MCP-Subscription-Id` (the subscription `id`) so the receiver can select the correct secret without parsing the body. This is the only MCP-specific header; everything else conforms to Standard Webhooks. -**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. +**Secret generation.** The signing secret is supplied by the client in `delivery.secret` and is REQUIRED — the server never generates one. This places the secret with the party that verifies deliveries (the gateway/receiver), so the receiver is ready to verify the first POST without any post-subscribe coordination, and a subscription pointed at an endpoint that didn't originate the secret fails verification by construction. Servers MUST reject a `delivery.secret` that is not `whsec_` followed by base64 decoding to 24–64 bytes (per Standard Webhooks); client SDKs SHOULD generate it from a CSPRNG by default. + +**Secret rotation.** The client supplies a new `delivery.secret` on a refresh call; the idempotent upsert replaces it. The server SHOULD dual-sign deliveries with both the old and new secrets for a short grace window (Standard Webhooks space-delimited multi-signature) so in-flight deliveries verify under either. There is no server-initiated rotation. **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. @@ -484,9 +482,9 @@ HMAC is verified by application code after the request has been routed. It does - **Method:** deliveries are HTTP `POST` only. - **Content-Type:** `application/json`. -- **Required headers:** `X-MCP-Signature`, `X-MCP-Timestamp`, and `X-MCP-Subscription-Id` are 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. +- **Required headers:** `webhook-id`, `webhook-timestamp`, `webhook-signature`, and `X-MCP-Subscription-Id` are present on every delivery (see *Signature scheme*). 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 treat `413` as 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). +- **TLS:** `https` is mandatory for `delivery.url`. 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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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. @@ -500,13 +498,14 @@ HMAC is verified by application code after the request has been routed. It does "method": "events/unsubscribe", "id": 3, "params": { - "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "name": "incident.created", + "params": { "severity": "P1" }, "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` resolves the subscription using the same key as `events/subscribe` — `(principal?, delivery.url, name, params)`. The client supplies `name`, `params`, and `delivery.url`; `principal` comes from the auth layer when present. The derived `id` is not accepted as input. `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). @@ -520,9 +519,11 @@ Enterprise governance tools can inspect the client SDK's subscription registry f 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. +**Replay is optional per event type.** A server MAY return `cursor: null` in any delivery (poll result, push `notifications/events/event` and `notifications/events/active`, webhook payload) when the event type does not support replay — typically because the upstream is push-only with no addressable history. A client receiving `cursor: null` MUST NOT attempt to persist or replay from it; on reconnect/resubscribe it sends `cursor: null` (start from now) and accepts that events during the gap are not recoverable. Servers SHOULD be consistent: an event type that ever returns a non-null cursor SHOULD always do so, and one that returns `null` SHOULD always return `null`, so clients can branch once at subscribe time rather than per delivery. -**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. +**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 (or `null` if the event type does not support replay). 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. `CursorExpired` always implies a possible gap — events between the supplied cursor and the new "now" may be unrecoverable — and the server generally cannot determine otherwise. Clients SHOULD treat it as a gap (e.g., re-fetch authoritative state via tools if it matters) and re-subscribe with `cursor: null`. For poll mode, this is a standard JSON-RPC error response to the `events/poll` request: @@ -540,10 +541,12 @@ For webhook mode, the server POSTs an error envelope to the callback URL (signed ``` POST https://proxy.example.com/hooks/client123 -X-MCP-Signature: sha256=<...> -X-MCP-Timestamp: 1739980800 +webhook-id: msg_err_a1b2c3d4 +webhook-timestamp: 1739980800 +webhook-signature: v1,<...> +X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e -{"id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","error":{"code":-32014,"message":"CursorExpired","data":{"reason":"Upstream history compacted"}}} +{"id":"sub_a3f1c8e2b0d49f7e","error":{"code":-32014,"message":"CursorExpired","data":{"reason":"Upstream history compacted"}}} ``` On `CursorExpired`, the client should reconnect the affected subscription with `cursor: null`. @@ -554,7 +557,7 @@ The server SDK should make implementing events as simple as implementing tools. 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. +- **Poll** is 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). The SDK SHOULD enable poll by default so it's available unless the author opts out. - **Push** is available when the transport supports streaming (Streamable HTTP or stdio). - **Webhook** is available when the server has `webhook_ttl` configured. @@ -709,7 +712,7 @@ 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 + # SDK generates a per-subscription whsec_ secret; pass secret_provider=... to supply your own ), ) @@ -737,11 +740,9 @@ The SDK intersects the event type's advertised `delivery` list with the modes th 1. **Webhook** if the client has `WebhookConfig` set and the server lists it — low latency without tying up a persistent connection. 2. **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. -3. **Poll** — always available; no server state, no persistent connection. +3. **Poll** — 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: +If none of the modes the server lists for that event type is usable by this client, the SDK raises `NoCompatibleDeliveryMode`. The caller can override per subscription: ```python # Force poll @@ -760,7 +761,7 @@ sub = await client.subscribe("incident.created", params={...}, delivery="webhook **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. +**Webhook mode:** The SDK generates a `whsec_` secret, registers it (or its derivation input) with the gateway, then calls `events/subscribe` with `delivery: {url, secret}`. The response provides the server-derived `id` for routing. It runs a background refresh loop, re-calling `events/subscribe` before `refreshBefore` expires. The SDK monitors `deliveryStatus` on each refresh to detect delivery failures. The webhook receiver (which may be a forward proxy) accepts incoming POSTs, looks up the secret by `X-MCP-Subscription-Id`, verifies the Standard Webhooks signature, 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 @@ -844,11 +845,9 @@ The spec does not mandate specific governance mechanisms but is designed to enab 3. **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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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. -4. **Should webhook delivery adopt the Standard Webhooks format instead of defining its own?** [Standard Webhooks](https://www.standardwebhooks.com/) specifies a delivery envelope very close to what this doc defines: `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers, `HMAC-SHA256(secret, id + "." + timestamp + "." + body)` with a `whsec_` prefix on secrets, base64 `v1,` 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 the `X-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 subscription `id` carried as `webhook-id`. +4. **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_changed` and `k8s.oom_killed` opens 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's `name` to 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. -5. **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_changed` and `k8s.oom_killed` opens 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's `name` to 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. - -6. **Should webhook deliveries support multiple signatures for zero-downtime secret rotation?** *(wire-breaking if adopted)* The current design has a single `X-MCP-Signature` header, and rotation happens via an atomic upsert of `delivery.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=,v1=,v1=`) 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 allow `delivery.secret` to be an array (or require servers to dual-sign for N seconds after rotation)? +> **Resolved (was: should webhook delivery adopt Standard Webhooks?)** — adopted as a profile; see *Signature scheme (Standard Webhooks profile)* under Webhook Security. This also resolves the multi-signature rotation question via Standard Webhooks' space-delimited `v1, v1,` format. ## Note on Consistency and Ordering @@ -898,10 +897,10 @@ sequenceDiagram rect rgb(245,245,250) Note over SDK,Redis: Subscribe (webhook delivery) - SDK->>Server: events/subscribe
{id, name: "pull_request.opened", params: {repo: "acme/webapp"},
delivery: {mode: "webhook", url: "https://proxy/.../hooks"}} - Server->>Redis: SETEX sub:{principal}:{urlHash}:{id} {ttl}
{name, params, url, secret} + SDK->>Server: events/subscribe
{name: "pull_request.opened", params: {repo: "acme/webapp"},
delivery: {mode: "webhook", url: "https://proxy/.../hooks", secret: "whsec_..."}} + Server->>Redis: SETEX sub:{derivedId} {ttl}
{principal, name, params, url, secret} Server->>GH: ensure repo webhook registered
(server-internal, idempotent) - Server-->>SDK: {secret, refreshBefore} + Server-->>SDK: {id, refreshBefore} end rect rgb(245,250,245) @@ -918,7 +917,7 @@ sequenceDiagram 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->>Redis: EXPIRE sub:{derivedId} {ttl} Server-->>SDK: {refreshBefore'} end Note over Redis: no refresh → key expires,
subscription gone From 58df0af65b3f33588da639547678f1d50993b7ef Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Thu, 30 Apr 2026 23:02:10 +0100 Subject: [PATCH 07/25] Revise Events design sketch: gap model, replay bounding, identity hardening, decisions table - Unify cursor expiry into in-band `truncated: true` (drop -32014 CursorExpired): poll result body, push re-sends notifications/events/active mid-stream, webhook refresh response + {"type":"gap"} control envelope; client never re-subscribes for a gap - Add `maxAge` (seconds) to bound replay across all modes; servers may decline by truncating to now - Heartbeat carries cursor (push) and refresh response carries the safe-watermark cursor (webhook) so the client's position advances during quiet periods - Webhook now requires an authenticated principal (key is (principal, url, name, params)); drop body `id` (route via X-MCP-Subscription-Id); add Non-event webhook bodies subsection (gap/terminated/verification with `type` discriminator) - Drop subscription `id` from poll/stream (one sub per request; correlate via JSON-RPC id / requestId) - Rewrite Summary as a how-it-works overview; add Key Design Decisions table; add OQ5 (task state changes as events) and OQ6 (endpoint verification + asymmetric server signing) - Consistency pass across diagrams, examples, error lists, and appendix step references --- docs/design-sketch-proposal.md | 209 +++++++++++++++++---------------- 1 file changed, 111 insertions(+), 98 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index 598bc64..20974c0 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -6,16 +6,13 @@ ## 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. +Events lets 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. A server declares event types via `events/list` (each with a name, an `inputSchema` for subscription params, a `payloadSchema`, and the delivery modes it supports). The client subscribes with `(name, params)` and receives event occurrences `{eventId, name, timestamp, data, cursor}`. -Key design principles: +**Delivery modes.** Three, advertised per event type; none is mandatory. **Poll** (`events/poll`) is request/response: client sends `{name, params, cursor}`, gets back `{events[], cursor, nextPollSeconds}`. **Push** (`events/stream`) is a long-lived request: events arrive as `notifications/events/event`, with a heartbeat carrying the current cursor during quiet periods. **Webhook** (`events/subscribe`) registers an `https` callback URL plus a client-supplied `whsec_` secret; the server POSTs each event there, signed per Standard Webhooks (`webhook-id/-timestamp/-signature` + `X-MCP-Subscription-Id`); subscriptions have a TTL and the client refreshes before `refreshBefore`. The client SDK negotiates the mode and runs the loop; the model only sees arriving events. -- **Servers advertise delivery modes; no mode is mandatory.** Each event type lists the delivery modes it supports. The client picks the best mode it can use. If there is no overlap between what the server offers and what the client can consume, that event type is simply unavailable to that client — the protocol does not require a universal fallback. (Reference SDKs are still encouraged to make poll cheap to support so that overlap is common in practice.) -- **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. +**Cursors and replay.** A cursor is an opaque server-defined position. The client persists the cursor it receives and passes it back on the next poll/reconnect/refresh; the server resumes from there. Servers whose upstream has no addressable history return `cursor: null` (no replay). `maxAge` bounds replay so a stale cursor doesn't dump an unbounded backlog. `eventId` (preferably the upstream's stable id) is for client-side dedup. Reliable, ordered delivery is achievable when the upstream is durable, but not mandated — `truncated: true` in any response signals events were skipped (stale cursor, `maxAge` floor, or server-side ceiling). + +**State and identity.** The client owns the canonical subscription list; the server holds nothing durable. Poll is per-request; push is connection-scoped; webhook requires an authenticated principal and is TTL'd soft state keyed on `(principal, delivery.url, name, params)` — the response returns a server-derived `id` for routing only. `events/unsubscribe` is eager cleanup; otherwise subscriptions lapse on their own. Authorization is the same MCP principal as for tools; event payloads are untrusted data with the same injection considerations as tool results. ## Capability Declaration @@ -112,12 +109,11 @@ There are three delivery modes with different subscription mechanisms: | `-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` | No subscription matches the supplied key 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`). +These codes are drawn from `-32011..-32017` (`-32014` is reserved/unused) to avoid collision with base MCP error codes (e.g., `-32002 ResourceNotFound`). ### Poll-Based Delivery @@ -130,8 +126,8 @@ sequenceDiagram participant Server as MCP Server loop every nextPollSeconds - SDK->>Server: events/poll {id, name, params, cursor} - Server-->>SDK: {events[], cursor, nextPollSeconds, hasMore} + SDK->>Server: events/poll {name, params, cursor} + Server-->>SDK: {events[], cursor, truncated, hasMore, nextPollSeconds} end Note over SDK: LLM invoked only when
events[] is non-empty ``` @@ -142,13 +138,13 @@ Each `events/poll` request carries one subscription. A client with multiple subs ```jsonc { - "id": "sub_email", // client-provided routing identifier "name": "email.received", "params": { "from": "*@anthropic.com", "redact_pii": true }, "cursor": null, // null = start from now + "maxAge": 300, // optional; do not replay events older than this many seconds (see Cursor Lifecycle) "maxEvents": 50 // optional; cap events returned } ``` @@ -157,7 +153,6 @@ Each `events/poll` request carries one subscription. A client with multiple subs ```jsonc { - "id": "sub_email", "events": [ { "eventId": "evt_001", @@ -172,6 +167,7 @@ Each `events/poll` request carries one subscription. A client with multiple subs } ], "cursor": "historyId_99842", + "truncated": false, "hasMore": false, "nextPollSeconds": 30 } @@ -179,7 +175,7 @@ Each `events/poll` request carries one subscription. A client with multiple subs #### `EventOccurrence` schema -Each entry in `events[]` (and the body of push/webhook deliveries) is an `EventOccurrence`: +Each entry in `events[]`, the params of `notifications/events/event` (plus `requestId`), and the body of webhook event deliveries is an `EventOccurrence` (webhook also sends non-event control bodies — see *Non-event webhook bodies*): | Field | Type | Required | Description | |---|---|---|---| @@ -191,7 +187,6 @@ Each entry in `events[]` (and the body of push/webhook deliveries) is an `EventO **Notes:** -- `id` is 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 same `id` for several subscriptions to fan results into a single handler. - `cursor` is opaque to the client. The client stores it and passes it back on the next poll. In the request, `null` means "start from now" — the server returns no events and provides a fresh cursor for subsequent polls. In the response, the server MAY return `cursor: null` for an event type that does not support replay; the client then has nothing to persist and always polls with `null` (see *Cursor Lifecycle*). - `eventId` enables client-side deduplication across polls (e.g., after a crash/restart). It is **server-assigned**: when the upstream source provides a stable event identifier (Stripe `evt_*`, GitHub delivery GUID, Kafka offset, Gmail message ID), the server SHOULD use that value as `eventId` so that the same upstream event surfaced via multiple paths (e.g., webhook emit and poll backfill) carries the same `eventId` and dedup works. The SDK auto-generates an `eventId` only when the author supplies none. - `maxEvents` is 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 sets `hasMore: true`. The client SHOULD poll again immediately (ignoring `nextPollSeconds`) to drain the backlog. If omitted, the server uses its own default limit. @@ -199,7 +194,7 @@ Each entry in `events[]` (and the body of push/webhook deliveries) is an `EventO - `nextPollSeconds` allows the server to dynamically adjust polling frequency (e.g., back off when rate-limited upstream, speed up when activity is detected). Ignored when `hasMore` is `true`. Clients SHOULD apply a configurable floor (default 1 second) to guard against a misbehaving server inducing a tight loop. - Empty `events` array 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. +- Errors (`EventNotFound`, `Unauthorized`, `InvalidParams`, `DeliveryModeUnsupported`) 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 @@ -216,11 +211,11 @@ sequenceDiagram participant SDK as Client SDK participant Server as MCP Server - SDK->>Server: events/stream {id, name, params, cursor} + SDK->>Server: events/stream {name, params, cursor} activate Server - Server-->>SDK: notifications/events/active {requestId, id, cursor} + Server-->>SDK: notifications/events/active {requestId, cursor, truncated} loop as events occur - Server-->>SDK: notifications/events/event {requestId, id, event, cursor} + Server-->>SDK: notifications/events/event {requestId, eventId, name, timestamp, data, cursor} end SDK->>Server: notifications/cancelled {requestId} (stdio)
— or connection close (Streamable HTTP) Server-->>SDK: StreamEventsResult (final frame) @@ -237,10 +232,10 @@ sequenceDiagram "method": "events/stream", "id": 1, "params": { - "id": "sub_email", "name": "email.received", "params": { "from": "*@anthropic.com", "redact_pii": true }, - "cursor": null + "cursor": null, + "maxAge": 300 } } ``` @@ -251,19 +246,19 @@ If the subscription is invalid (`EventNotFound`, `Unauthorized`, `InvalidParams` ```jsonc // Confirmation -{"jsonrpc":"2.0","method":"notifications/events/active","params":{"requestId":1,"id":"sub_email","cursor":"historyId_99840"}} +{"jsonrpc":"2.0","method":"notifications/events/active","params":{"requestId":1,"cursor":"historyId_99840","truncated":false}} // 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"}} +{"jsonrpc":"2.0","method":"notifications/events/event","params":{"requestId":1,"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"}}} +{"jsonrpc":"2.0","method":"notifications/events/error","params":{"requestId":1,"error":{"code":-32603,"message":"UpstreamError","data":{"reason":"Gmail API 503"}}}} // 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. +`notifications/events/error` reports a recoverable failure (e.g., a single upstream fetch failed); the subscription remains active and the server retries and resumes. Only `notifications/events/terminated` (see *Authorization*) ends the subscription. A gap (e.g., the cursor fell outside the upstream's retention window) is *not* an error — the server sends a fresh `notifications/events/active {requestId, cursor:, truncated:true}` and continues delivering (see *Cursor Lifecycle*). 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. @@ -272,9 +267,9 @@ The `events/stream` response carries only `notifications/events/*` messages. Non #### Lifecycle - **Stream termination.** The `StreamEventsResult` is 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 to `events/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. +- **Heartbeat.** The server MUST send periodic keepalive messages on the push stream so the client can distinguish "nothing to send" from "connection is dead." The heartbeat is `{"jsonrpc":"2.0","method":"notifications/events/heartbeat","params":{"requestId":1,"cursor":"historyId_99850"}}` — `cursor` is the position the server has checked up to, so the client's persisted cursor advances even when no events match (see *Cursor Lifecycle*); it is `null` for event types that do not support replay. On Streamable HTTP this is sent as an SSE `data:` frame; the SSE comment form (`: keepalive`) is not used since it cannot carry cursor state. The server SHOULD send a heartbeat at least every 30 seconds. Clients SHOULD NOT apply their default request timeout to `events/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/cancelled` with the `requestId` matching the `events/stream` request's `id`. In both cases, the server MUST stop delivering events and release any associated resources. On stdio, the server MAY then send the `StreamEventsResult`; 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/stream` requests 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 via `requestId`. +- **Concurrent streams.** A client MAY hold multiple concurrent `events/stream` requests 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 via `requestId`. Server SDKs MUST exempt `events/stream` from any general request-concurrency cap, since each push subscription is a long-lived request that never completes until cancelled. - **Reconnection after failure.** If the connection drops (HTTP) or the server stops sending (stdio), the client sends a new `events/stream` with the same subscription and its last-known cursor. #### Cursor Advancement @@ -299,15 +294,15 @@ sequenceDiagram participant Hook as Webhook Endpoint SDK->>Server: events/subscribe {name, params, delivery: {url, secret}, cursor} - Server-->>SDK: {id, refreshBefore} + Server-->>SDK: {id, refreshBefore, cursor, truncated} loop as events occur - Server->>Hook: POST {id, event, cursor} + HMAC signature + Server->>Hook: POST {eventId, name, timestamp, data, cursor} + Standard Webhooks signature Hook-->>Server: 200 OK Hook--)SDK: deliver event (implementation-defined,
e.g. forward proxy → poll/push) end loop before each refreshBefore SDK->>Server: events/subscribe (same key — refreshes TTL) - Server-->>SDK: {refreshBefore'} + Server-->>SDK: {refreshBefore', cursor, deliveryStatus} end opt explicit teardown (else: stop refreshing → TTL expiry) SDK->>Server: events/unsubscribe {name, params, delivery: {url}} @@ -332,7 +327,8 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau "url": "https://proxy.example.com/hooks/client123", "secret": "whsec_NWNmOGE3YjJkNGU2ZjgwMTIzNDU2Nzg5YWJjZGVmMDE" }, - "cursor": null + "cursor": null, + "maxAge": 300 } } ``` @@ -340,8 +336,10 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau ```jsonc // Response { - "id": "sub_a3f1c8e2b0d49f7e", // server-derived; stable for this (principal?, url, name, params) - "refreshBefore": "2026-02-19T16:30:00Z" + "id": "sub_a3f1c8e2b0d49f7e", // server-derived; stable for this (principal, url, name, params) + "refreshBefore": "2026-02-19T16:30:00Z", + "cursor": "cursor_start_001", // safe-to-persist watermark (same semantics as payload cursor); advances the client's cursor even if no events arrive before next refresh (see Cursor Lifecycle) + "truncated": false // true if delivery started later than the supplied cursor (retention window, maxAge floor, or server-side ceiling) } ``` @@ -349,9 +347,10 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau - `events/subscribe` is ONLY used for webhook delivery. Poll and push do not need it. - `delivery.secret` is REQUIRED. The client supplies the HMAC signing secret; the server never generates one. The value MUST be a Standard Webhooks symmetric secret: the literal prefix `whsec_` followed by base64 of 24–64 random bytes. Servers MUST reject values that do not satisfy this with `InvalidParams`. Client SDKs SHOULD generate the secret on the application's behalf rather than expose an interface that encourages hand-picked values. -- `id` (response only) is a server-derived handle for the subscription, deterministic over `(principal?, delivery.url, name, params)`. The server returns it so the receiver can route deliveries (it appears in the `X-MCP-Subscription-Id` header on every POST). It is stable across refreshes and server restarts. The client does not generate or send it. -- `cursor` (request only) tells the server where to begin delivery. `null` means "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 that `cursor` and 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. +- `id` (response only) is a server-derived handle for the subscription, deterministic over `(principal, delivery.url, name, params)`. The server returns it so the receiver can route deliveries (it appears in the `X-MCP-Subscription-Id` header on every POST). It is stable across refreshes and server restarts. The client does not generate or send it. +- `cursor` (request) tells the server where to begin delivery. `null` means "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 per-subscription cursor position across restarts; it computes a safe-to-persist watermark for inclusion in each delivered payload (see *Webhook Event Delivery*) and returns that same watermark in each subscribe/refresh response so the client's cursor advances during quiet periods. Both sources carry the same value semantics, so the client persists whichever it receives most recently and 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. - `refreshBefore` is mandatory. It is an ISO 8601 timestamp indicating when the subscription will expire. The client MUST re-call `events/subscribe` with the same subscription key before this time to keep the subscription alive. The server resets the TTL on each refresh. +- `cursor` (response) is the same **safe-to-persist watermark** the server includes in delivery payloads — i.e., a position at or before which everything has been acknowledged or abandoned. Returning it here lets the client's cursor advance during quiet periods when no event POSTs arrive; without this, a long quiet stretch could leave the client holding a cursor older than the upstream's retention window. When deliveries are in flight, the response cursor is at or behind the upstream head (it does not advance past unacked events), so persisting it never skips anything. It is `null` for event types that do not support replay. - `events/subscribe` is 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 (event name, params, callback URL, secret, derived id) 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*). @@ -359,7 +358,9 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau 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 `(principal?, delivery.url, name, params)`, where `principal` is the server's canonical identifier for the authenticated subject (e.g., OAuth `sub`, API key ID, service-account name) and is included when present. `params` is compared by canonical-JSON equality. There is no client-generated `id` — a subscription is fully determined by what it listens for, where it delivers, and (when authenticated) who asked. +**Authentication required.** `events/subscribe` and `events/unsubscribe` MUST be called with an authenticated principal; servers MUST reject unauthenticated calls with `-32012 Unauthorized`. Without a principal in the key, the tuple `(delivery.url, name, params)` is guessable and any caller could unsubscribe or rotate another tenant's secret. Unauthenticated MCP servers may offer poll and push, but not webhook. + +**Key composition.** The subscription key is `(principal, delivery.url, name, params)`, where `principal` is the server's canonical identifier for the authenticated subject (e.g., OAuth `sub`, API key ID, service-account name). `params` is compared by canonical-JSON equality. There is no client-generated `id` — a subscription is fully determined by what it listens for, where it delivers, and who asked. All four components are immutable for the subscription's lifetime: a subscribe call with a different value for any of them addresses a different subscription. To change what a subscription listens for or where it delivers, `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. @@ -374,7 +375,7 @@ On an idempotent subscribe against an existing key, the server updates the remai | 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 key includes `delivery.url` (and `principal` when authenticated), two distinct tenants subscribing to the same `(name, params)` get distinct subscriptions. A caller who learns another tenant's derived `id` gains nothing — `id` is not accepted as input to any method. +**Cross-tenant isolation.** Because the key includes `principal` and `delivery.url`, two distinct tenants subscribing to the same `(name, params)` get distinct subscriptions. A caller who learns another tenant's derived `id` gains nothing — `id` is not accepted as input to any method. #### Webhook Event Delivery @@ -389,7 +390,6 @@ webhook-signature: v1, X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e { - "id": "sub_a3f1c8e2b0d49f7e", "eventId": "evt_789", "name": "incident.created", "timestamp": "2026-02-19T16:00:00Z", @@ -404,14 +404,24 @@ X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e **Notes:** -- Deliveries follow the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) signature scheme. `webhook-id` carries the `eventId` (per-delivery, used for dedup). `webhook-timestamp` is the Unix timestamp (seconds) of the request. `webhook-signature` is `v1,` where the value is `HMAC-SHA256(secret, webhook-id + "." + webhook-timestamp + "." + body)`; multiple space-delimited signatures MAY be present during secret rotation. `X-MCP-Subscription-Id` is an MCP-specific header (not part of Standard Webhooks) carrying the subscription `id` so the receiver can select the correct secret before parsing the body. The receiver MUST verify the signature before processing, SHOULD reject deliveries where `webhook-timestamp` is more than 5 minutes old, and SHOULD deduplicate on `webhook-id`. Each retry attempt regenerates the timestamp and signature. +- Deliveries follow the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) signature scheme. `webhook-id` carries the `eventId` for event deliveries (per-delivery, used for dedup; control envelopes use `msg__` — see *Non-event webhook bodies*). `webhook-timestamp` is the Unix timestamp (seconds) of the request. `webhook-signature` is `v1,` where the value is `HMAC-SHA256(secret, webhook-id + "." + webhook-timestamp + "." + body)`; multiple space-delimited signatures MAY be present during secret rotation. `X-MCP-Subscription-Id` is an MCP-specific header (not part of Standard Webhooks) carrying the subscription `id` so the receiver can select the correct secret before parsing the body. The receiver MUST verify the signature before processing, SHOULD reject deliveries where `webhook-timestamp` is more than 5 minutes old, and SHOULD deduplicate on `webhook-id`. Each retry attempt regenerates the timestamp and signature. - `eventId` in the body enables idempotent processing. The receiver SHOULD deduplicate by this value. -- `cursor` in 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 include `cursor_N` in event N's payload until events at positions `< N` have 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 server MAY send `cursor: null` for event types that do not support replay (see *Cursor Lifecycle*); in that case there is no recovery point to persist. The endpoint MUST make `cursor` and `eventId` available 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. +- `cursor` in 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 include `cursor_N` in event N's payload until events at positions `< N` have been acked or given up on. Computing this requires the server to hold, per active subscription, the upstream position and ack status of in-flight events; this is in-memory TTL-scoped state (lost on restart, reconstructed by client refresh), but it is per-subscription bookkeeping the SDK must implement. The client persists every cursor it receives; the most recently received value is always safe to supply on refresh. The server MAY send `cursor: null` for event types that do not support replay (see *Cursor Lifecycle*); in that case there is no recovery point to persist. The endpoint MUST make `cursor` and `eventId` available 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-`2xx` responses. 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 uses `eventId` for deduplication and `timestamp` for ordering if needed. Because the payload `cursor` is 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 `2xx` response from the webhook endpoint signals that the event has been accepted and the server need not retry it. The endpoint SHOULD NOT return `2xx` until 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.** Because the secret is client-supplied, the receiver can verify the very first delivery — there is no window where a delivery arrives before the receiver knows the secret. A receiver that gets a delivery for an `id` it has not yet been told to route (e.g., the subscribe response has not propagated to the gateway) SHOULD return a retryable status (`503` or `425 Too Early`); the server's retry/backoff redelivers once the receiver is ready. `eventId` deduplication 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 (sets `active: true`) and the server resumes retrying pending events; if the client never refreshes, the subscription expires naturally at TTL. +#### Non-event webhook bodies + +In addition to `EventOccurrence` bodies, the server POSTs control envelopes to the callback URL. A body with a top-level `type` field is a control envelope; a body without one is an `EventOccurrence`. These are signed and headed exactly like event deliveries (Standard Webhooks headers + `X-MCP-Subscription-Id`); the body carries a `type` discriminator instead of `eventId`/`data`. `webhook-id` for control envelopes is a per-message identifier of the form `msg__` so receivers can dedup retries. The endpoint MUST forward control envelopes to the consuming client by the same channel it forwards events. + +| `type` | Body | Sent when | +|---|---|---| +| `gap` | `{"type":"gap","cursor":""}` | A gap is detected between refreshes (see *Gaps and `truncated`*). The client persists `cursor` and treats it as `truncated: true`. | +| `terminated` | `{"type":"terminated","error":{"code":...,"message":...,"data":...}}` | The subscription has ended (e.g., authorization revoked). The subscription no longer exists server-side. | +| `verification` | `{"type":"verification","challenge":""}` | *(Pending — see Open Question 6.)* | + #### 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: @@ -421,6 +431,8 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre { "id": "sub_a3f1c8e2b0d49f7e", "refreshBefore": "2026-02-19T17:00:00Z", + "cursor": "cursor_xyz", + "truncated": false, "deliveryStatus": { "active": true, "lastDeliveryAt": "2026-02-19T16:28:00Z", @@ -434,6 +446,8 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre { "id": "sub_a3f1c8e2b0d49f7e", "refreshBefore": "2026-02-19T17:00:00Z", + "cursor": "cursor_xyz", + "truncated": false, "deliveryStatus": { "active": false, "lastDeliveryAt": "2026-02-19T15:45:00Z", @@ -449,24 +463,11 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre **SSRF prevention.** The server MUST validate callback URLs. Servers SHOULD reject URLs whose resolved IP is not globally routable per the [IANA IPv4](https://www.iana.org/assignments/iana-ipv4-special-registry/) and [IPv6](https://www.iana.org/assignments/iana-ipv6-special-registry/) 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 -webhook-id: msg_verify_9f86d081 -webhook-timestamp: 1739980800 -webhook-signature: v1,<...> -X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e - -{"type":"verification","id":"sub_a3f1c8e2b0d49f7e","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. +**Endpoint verification.** *(Open — see Open Question 6.)* Whether the server performs an ownership challenge before activating delivery, what that challenge carries, and whether it is MUST or SHOULD given client-supplied HMAC, are not yet settled. The interaction with optional asymmetric server signing (sender identity for gateway allowlists) is part of the same decision. **TLS requirement.** Callback URLs MUST use `https://`. Servers MUST reject `events/subscribe` with a non-`https` `delivery.url` (`-32015 InvalidCallbackUrl`). Event payloads and the `delivery.secret` (sent in the subscribe request) would otherwise transit in cleartext. -**Signature scheme (Standard Webhooks profile).** MCP webhook delivery is a profile of [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md). Every delivery MUST include `webhook-id` (the `eventId`), `webhook-timestamp` (Unix seconds), and `webhook-signature`. The signature is `HMAC-SHA256(secret, webhook-id + "." + webhook-timestamp + "." + body)` encoded as base64 with a `v1,` prefix. In this formula, `body` is the raw HTTP request body bytes exactly as received, before any parsing or re-serialization; the `webhook-id + "." + webhook-timestamp + "."` prefix is UTF-8; and `secret` is the base64-decoded bytes of the value after the `whsec_` prefix. Receivers MUST compute the HMAC over the raw body, never over a re-serialized JSON object. The header MAY contain multiple space-delimited signatures (`v1, v1,`) during secret rotation; the receiver accepts if any verifies. Each retry attempt MUST regenerate the timestamp and signature so retries are not rejected by the receiver's freshness window. The receiver SHOULD reject deliveries where `webhook-timestamp` is more than 5 minutes old. Off-the-shelf Standard Webhooks verifiers (e.g., Svix libraries) work without modification. +**Signature scheme (Standard Webhooks profile).** MCP webhook delivery is a profile of [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md). Every delivery MUST include `webhook-id` (the `eventId` for event deliveries; `msg__` for control envelopes), `webhook-timestamp` (Unix seconds), and `webhook-signature`. The signature is `HMAC-SHA256(secret, webhook-id + "." + webhook-timestamp + "." + body)` encoded as base64 with a `v1,` prefix. In this formula, `body` is the raw HTTP request body bytes exactly as received, before any parsing or re-serialization; the `webhook-id + "." + webhook-timestamp + "."` prefix is UTF-8; and `secret` is the base64-decoded bytes of the value after the `whsec_` prefix. Receivers MUST compute the HMAC over the raw body, never over a re-serialized JSON object. The header MAY contain multiple space-delimited signatures (`v1, v1,`) during secret rotation; the receiver accepts if any verifies. Each retry attempt MUST regenerate the timestamp and signature so retries are not rejected by the receiver's freshness window. The receiver SHOULD reject deliveries where `webhook-timestamp` is more than 5 minutes old. Off-the-shelf Standard Webhooks verifiers (e.g., Svix libraries) work without modification. In addition to the Standard Webhooks headers, deliveries MUST include `X-MCP-Subscription-Id` (the subscription `id`) so the receiver can select the correct secret without parsing the body. This is the only MCP-specific header; everything else conforms to Standard Webhooks. @@ -505,9 +506,9 @@ HMAC is verified by application code after the request has been routed. It does } ``` -`events/unsubscribe` resolves the subscription using the same key as `events/subscribe` — `(principal?, delivery.url, name, params)`. The client supplies `name`, `params`, and `delivery.url`; `principal` comes from the auth layer when present. The derived `id` is not accepted as input. +`events/unsubscribe` resolves the subscription using the same key as `events/subscribe` — `(principal, delivery.url, name, params)`. The client supplies `name`, `params`, and `delivery.url`; `principal` comes from the auth layer. The derived `id` is not accepted as input. -`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). +`events/unsubscribe` is ONLY used for webhook delivery. Poll subscriptions are implicit (stop polling to unsubscribe). Push subscriptions are scoped to the `events/stream` request (close or cancel to unsubscribe). ## Listing Active Subscriptions @@ -521,35 +522,21 @@ Cursors are opaque strings managed by the server. They represent a position in t **Replay is optional per event type.** A server MAY return `cursor: null` in any delivery (poll result, push `notifications/events/event` and `notifications/events/active`, webhook payload) when the event type does not support replay — typically because the upstream is push-only with no addressable history. A client receiving `cursor: null` MUST NOT attempt to persist or replay from it; on reconnect/resubscribe it sends `cursor: null` (start from now) and accepts that events during the gap are not recoverable. Servers SHOULD be consistent: an event type that ever returns a non-null cursor SHOULD always do so, and one that returns `null` SHOULD always return `null`, so clients can branch once at subscribe time rather than per delivery. -**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 (or `null` if the event type does not support replay). No historical events are replayed. +**Initial cursor:** Passing `cursor: null` in any mode means "start from now." The server returns a fresh cursor representing the current position (or `null` if the event type does not support replay). 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. `CursorExpired` always implies a possible gap — events between the supplied cursor and the new "now" may be unrecoverable — and the server generally cannot determine otherwise. Clients SHOULD treat it as a gap (e.g., re-fetch authoritative state via tools if it matters) and re-subscribe with `cursor: null`. +**Cursor advancement without events.** The client's persisted cursor must advance during quiet periods so it doesn't fall outside the upstream's retention window when nothing is happening. Poll covers this inherently (every response carries `cursor`, including when `events: []`). For push, the heartbeat carries `cursor` (see *Heartbeat* under Push-Based Delivery). For webhook, each `events/subscribe` refresh response carries `cursor`. In all three the value is safe to persist (for webhook it is the same watermark as in delivery payloads, never ahead of unacked events); the client persists it exactly as it would a cursor from a delivered event. -For poll mode, this is a standard JSON-RPC error response to the `events/poll` request: +**Bounding replay with `maxAge`.** All three modes accept an optional `maxAge` (integer seconds) alongside `cursor`. When present, the server begins replay from whichever is *later*: the supplied `cursor`, or `now − maxAge`. This lets a client resubscribe with a stale cursor without receiving an unbounded backlog — e.g., a host that was offline for a week can pass its persisted cursor with `maxAge: 300` to get at most the last five minutes. If the floor advances past the cursor, the server SHOULD set `truncated: true` on the first response (poll result / `notifications/events/active` / webhook subscribe response) so the client knows older events were skipped. Servers whose upstream supports time-addressed reads (e.g., Gmail `after:`, Slack `oldest=`, Kafka `offsetsForTimes`) SHOULD seek directly; others MAY honour `maxAge` by replaying from `cursor` and discarding events whose `timestamp` is older than the floor — or, if that would be prohibitively expensive (e.g., a week-old cursor with `maxAge: 300`), MAY instead reset to now and return `truncated: true`, which yields the same client-visible outcome without the wasted scan. Servers MAY also apply their own replay ceiling independent of `maxAge` and MUST signal it via `truncated: true` when they do. `maxAge` is ignored when `cursor` is `null` (null already means "now"; replay from before the subscription's start is out of scope per *What Is NOT in v1*) and for event types that do not support replay. -```jsonc -{"jsonrpc":"2.0","id":7,"error":{"code":-32014,"message":"CursorExpired","data":{"reason":"Upstream history compacted"}}} -``` +**Gaps and `truncated`.** `truncated: true` is the single signal that the server started delivery from a position later than the cursor the client supplied — i.e., events were skipped. Causes are not distinguished on the wire: the supplied cursor may have fallen outside the upstream's retention window, the `maxAge` floor may have advanced past it, or the server may have applied its own replay ceiling. In all cases the server resets to a position it can serve from, returns that position as the fresh `cursor` alongside `truncated: true`, and continues — the client never reconnects or re-subscribes in response. `truncated: true` always implies a possible gap; clients SHOULD treat it as such (e.g., re-fetch authoritative state via tools if it matters) and persist the fresh cursor. -For push mode, this appears as a notification on the stream: +| Mode | Where `truncated` appears | +|---|---| +| Poll | The result body: `{events:[], cursor:, truncated:true, hasMore, nextPollSeconds}`. Never a JSON-RPC error. | +| Push | A `notifications/events/active {requestId, cursor:, truncated:true}` is sent (initially, and again mid-stream if a gap occurs); delivery continues on the same stream. | +| Webhook | The subscribe/refresh response: `{id, refreshBefore, cursor:, truncated:true, ...}`. For a gap detected between refreshes, the server POSTs a `{"type":"gap","cursor":""}` control envelope (see *Non-event webhook bodies*) so the client learns of it without waiting for the next refresh. | -``` -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 -webhook-id: msg_err_a1b2c3d4 -webhook-timestamp: 1739980800 -webhook-signature: v1,<...> -X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e - -{"id":"sub_a3f1c8e2b0d49f7e","error":{"code":-32014,"message":"CursorExpired","data":{"reason":"Upstream history compacted"}}} -``` - -On `CursorExpired`, the client should reconnect the affected subscription with `cursor: null`. +For event types that do not support replay (`cursor` is always `null`), `truncated` SHOULD be `false` and clients SHOULD ignore it — there is no position to have advanced past. ## Server SDK Guidance @@ -561,7 +548,7 @@ By default the server author does not declare which delivery modes an event type - **Push** is available when the transport supports streaming (Streamable HTTP or stdio). - **Webhook** is available when the server has `webhook_ttl` configured. -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. +The SDK computes the `delivery` array in `events/list` responses from the above, minus any 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. ```python @server.event( @@ -664,7 +651,7 @@ 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. +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 `truncated: true` with a fresh cursor. 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. ```python server = MCPServer( @@ -693,7 +680,7 @@ async def on_unsubscribe(context, params, subscription_id): 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: +**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-or-null, eventName, canonicalHash(params))` — on unauthenticated servers all callers share one lease per `(name, params)`. `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: ```python server = MCPServer( @@ -732,7 +719,7 @@ async for event in sub: 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. +The SDK manages delivery mode selection, poll loops, push stream lifecycle, webhook refresh cycles, cursor tracking, deduplication by `eventId`, gap/`truncated` handling, and reconnection — all transparently. ### Delivery Mode Selection @@ -787,7 +774,7 @@ This reduces PII exposure in event infrastructure (logs, queues, buffers) and li ### 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. +**Subscribe-time:** When the caller is authenticated, the server MUST verify the principal has permission to subscribe to the requested event type with the given params (e.g., a Slack server verifies the user has access to the specified channel). Unauthenticated servers — which may offer poll and push but not webhook — apply whatever server-side policy they choose, including accepting all subscriptions. **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: @@ -795,17 +782,17 @@ This reduces PII exposure in event infrastructure (logs, queues, buffers) and li |---|---| | 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 | +| Webhook | Signed `{"type":"terminated",...}` envelope POSTed to the callback URL (see *Non-event webhook bodies*); the subscription no longer exists, so a subsequent refresh is a fresh subscribe and returns `-32012 Unauthorized` if the cause of termination still applies | ``` // Push -data: {"jsonrpc":"2.0","method":"notifications/events/terminated","params":{"requestId":1,"id":"sub_slack","error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}}}} +data: {"jsonrpc":"2.0","method":"notifications/events/terminated","params":{"requestId":1,"error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}}}} // Webhook (POST body) -{"id":"sub_slack","error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}}} +{"type":"terminated","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. +`notifications/events/terminated` has the shape `{requestId: string|integer, 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. @@ -847,7 +834,12 @@ The spec does not mandate specific governance mechanisms but is designed to enab 4. **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_changed` and `k8s.oom_killed` opens 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's `name` to 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. -> **Resolved (was: should webhook delivery adopt Standard Webhooks?)** — adopted as a profile; see *Signature scheme (Standard Webhooks profile)* under Webhook Security. This also resolves the multi-signature rotation question via Standard Webhooks' space-delimited `v1, v1,` format. +5. **Should MCP task state changes be exposed as events?** Tasks are MCP's long-running-operation primitive; clients currently learn of state transitions via push notifications on a live connection. Exposing a reserved `mcp.task.updated` event type (params `{taskId}`, payload `{status, progress?, result?}`) would let a client that started a task and disconnected receive the completion via poll or webhook — useful for "kick off a long job, close the laptop, get notified when done." Same considerations as question 2: reserved `mcp.*` prefix, migration path, and whether one mechanism subsuming another is worth the coupling. + +6. **Endpoint verification and server-identity signing — what is required, and how do they relate?** Two intertwined sub-questions: + - *Ownership challenge.* With client-supplied HMAC secrets, a delivery to an endpoint that didn't originate the secret already fails verification, so a nonce-echo challenge is arguably defence-in-depth (fail-fast on bad URLs; bound spam to one POST) rather than the load-bearing control. Options: MUST (prior text), SHOULD, or omit. If kept, the challenge is an HMAC-signed `{"type":"verification","id":...,"challenge":...}` POST; the endpoint echoes the token. + - *Asymmetric server signing.* Servers MAY additionally sign deliveries with an ed25519 key (Standard Webhooks `v1a,`), with the public key published via JWKS at the server's origin (potentially via SEP-2127 server cards). This lets a gateway verify *which server* sent a delivery and enforce a server allowlist, and provides defence-in-depth if an HMAC secret leaks. Key distribution must be anchored to an origin the gateway already trusts — a key or JWKS URL carried in the challenge itself is trust-on-first-use and can be attacker-supplied. + These need to be resolved together: the answer to "is the challenge MUST" depends on whether asymmetric signing is expected to be common, and both depend on what threat the gateway is defending against (third-party-target abuse vs server impersonation vs leaked secret). ## Note on Consistency and Ordering @@ -881,6 +873,27 @@ Head-of-line blocking — where a slow-to-process event delays delivery of subse 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. +## Key Design Decisions + +These are the choices where reasonable alternatives existed; recorded so reviewers can challenge the trade-off rather than re-derive the option space. + +| Decision | Choice | Rationale | +|---|---|---| +| Mandatory delivery mode | None — servers advertise any non-empty subset of poll/push/webhook | Universal compatibility would be nice, but mandating any single mode places an unacceptable burden on implementers (poll needs a history or buffer; push needs a held connection; webhook needs outbound HTTP + SSRF hardening). The extension is already opt-in. May revisit if deployments converge on a common subset in practice. | +| Webhook signature scheme | Adopt Standard Webhooks as a profile (`webhook-id`/`-timestamp`/`-signature`, `v1,` HMAC, `whsec_` secrets, multi-sig rotation) | Gets off-the-shelf verifier libraries in most languages and a defined asymmetric (`v1a,`) path for free. Trade-off is a dependency on an external community spec, but it's small, stable, pinnable, and not a one-way door. | +| Batched poll/stream | One subscription per `events/poll`/`events/stream` request | Batching is a transport-level concern; HTTP/2 multiplexing makes per-subscription requests cheap. Avoids the protocol complexity batching introduces (coalescing heterogeneous `nextPollSeconds`, partial-failure result shapes, per-entry error routing, duplicate-id handling). Clients with many subscriptions on HTTP/1.1 should expect to coalesce at the transport, not the protocol. | +| Webhook secret generation | Client-supplied, REQUIRED, per subscription (`whsec_` + 24–64 B) | Avoids the post-subscribe write-back race of server-generated per-sub secrets and the out-of-band registration step of per-endpoint secrets. Crucially, HMAC then proves "this delivery is for a subscription *this endpoint* originated," not merely "this came from the server" — closing third-party-target abuse without a mandatory challenge. | +| Multimedia / high-throughput streams | Out of scope | Streaming media has fundamentally different transport, framing, and back-pressure requirements. Designing for human/automation-scale event rates keeps the SDK surface tractable; a media primitive would be a separate proposal. | +| Reliable / ordered delivery | Supported (via opaque cursors + `eventId` dedup), not mandated | Genuine use cases need it, so the cursor abstraction makes at-least-once-with-replay achievable when the upstream is durable. But many upstreams are push-only with no history; mandating reliability would force them to fake it. Servers may return `cursor: null` to opt out. | +| Webhook subscription identity | `(principal, delivery.url, name, params)`; webhook requires auth; server-derived `id` for routing only | The tuple is everything that semantically defines a subscription, so subscribe is naturally idempotent and there is nothing for the client to generate or persist. The derived `id` is a non-capability routing handle. Webhook requires an authenticated principal so the tuple is not guessable by other tenants; without it, anyone could unsubscribe or rotate another tenant's secret. Server-generated and client-generated `id` schemes were considered; both add state or entropy obligations the tuple makes unnecessary. | +| Server-side subscription durability | None — push is connection-scoped, webhook is TTL'd soft state, poll is per-request | Clients hold the canonical subscription list and reconstruct server state by re-subscribing. A server crash never orphans a client; a vanished client never leaks server resources. Trade-off: no server-side "list all subscriptions" without per-principal enumeration. | +| Webhook callback TLS | `https` is MUST | Both event payloads and the `delivery.secret` (in the subscribe request) would otherwise transit in cleartext. No legitimate remote-server deployment lacks TLS in 2026. | +| Replay bound | Time-floor `maxAge` only; no count bound | Time-anchored start is the universal primitive across replayable systems (Kinesis `AT_TIMESTAMP`, JetStream `by_start_time`, Kafka `offsetsForTimes`, RabbitMQ/Pulsar/PubSub seek-to-time). Count-bounded subscribe ("newest N") requires backward-seek that many upstreams can't do cheaply and has ambiguous which-end semantics. | +| Cursor advancement during quiet periods | Heartbeat carries cursor (push); refresh response carries cursor (webhook); poll already returns cursor on empty results | Without this, a long quiet stretch leaves the client holding a cursor older than the upstream's retention window, yielding a spurious `truncated: true` even though nothing happened. Reuses existing periodic messages; no new traffic. | +| `eventId` source | Server-assigned; SHOULD be the upstream's stable identifier | Dual-path delivery (e.g., webhook emit + poll backfill) of the same upstream event must collapse under client-side dedup. SDK auto-generates only when the author supplies none. | +| Protocol-level flow control | None in v1 | The dominant bottleneck is LLM inference (seconds–minutes per event), not delivery. TCP back-pressure, `nextPollSeconds`, and client-side prioritization are sufficient for the target event rates. Credit-based control can be added later without breaking changes. | +| New primitive vs extending `resources/subscribe` | New `events/*` primitive | Resources are content-addressed (URI → representation) without parameters or cursors; bolting filter params, replay, and three delivery modes onto them would distort that model more than a parallel primitive costs. Folding `resources/subscribe` into events later is left as an open question. | + ## 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. @@ -900,7 +913,7 @@ sequenceDiagram SDK->>Server: events/subscribe
{name: "pull_request.opened", params: {repo: "acme/webapp"},
delivery: {mode: "webhook", url: "https://proxy/.../hooks", secret: "whsec_..."}} Server->>Redis: SETEX sub:{derivedId} {ttl}
{principal, name, params, url, secret} Server->>GH: ensure repo webhook registered
(server-internal, idempotent) - Server-->>SDK: {id, refreshBefore} + Server-->>SDK: {id, refreshBefore, cursor, truncated} end rect rgb(245,250,245) @@ -908,7 +921,7 @@ sequenceDiagram GH->>Server: POST /gh-webhook
{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'}
+ HMAC signature + Server->>Proxy: POST {eventId, name, timestamp, data: {number: 42, title, author, url}, cursor'}
+ Standard Webhooks signature Proxy-->>Server: 200 OK Proxy--)SDK: deliver event
(implementation-defined, e.g. poll/push) SDK->>LLM: invoke agent with event payload @@ -918,16 +931,16 @@ sequenceDiagram Note over SDK,Redis: Keepalive SDK->>Server: events/subscribe (same key — refresh TTL) Server->>Redis: EXPIRE sub:{derivedId} {ttl} - Server-->>SDK: {refreshBefore'} + Server-->>SDK: {refreshBefore', cursor, deliveryStatus} end Note over Redis: no refresh → key expires,
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. +Note there are *two* webhook hops here, and they are unrelated: GitHub → MCP Server (step 5) is GitHub's native webhook product, configured server-side and outside this spec; MCP Server → Forward Proxy (step 8) 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 single `notifications/events/event` from server to SDK (see [Push-Based Delivery](#push-based-delivery)). -- **Poll** — drop step 1 and the Forward Proxy lane; the SDK loops `events/poll` with the subscription inline, and step 5–7 become the poll response (see [Poll-Based Delivery](#poll-based-delivery)). +- **Push** — step 1 becomes `events/stream`; drop the Forward Proxy lane and the Keepalive box; steps 8–10 collapse into a single `notifications/events/event` from server to SDK (see [Push-Based Delivery](#push-based-delivery)). +- **Poll** — drop step 1 and the Forward Proxy lane; the SDK loops `events/poll` with the subscription inline, and steps 8–10 become the poll response (see [Poll-Based Delivery](#poll-based-delivery)). From 8c6a1343cc98ae160555a3f153b36505d463e8a2 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Thu, 30 Apr 2026 23:11:37 +0100 Subject: [PATCH 08/25] Redact example secret to avoid triggering secret scanners --- docs/design-sketch-proposal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index 20974c0..d3d7543 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -325,7 +325,7 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau "delivery": { "mode": "webhook", "url": "https://proxy.example.com/hooks/client123", - "secret": "whsec_NWNmOGE3YjJkNGU2ZjgwMTIzNDU2Nzg5YWJjZGVmMDE" + "secret": "whsec_" }, "cursor": null, "maxAge": 300 From d4faef9758a64122f5488c4091b4d43e4f9f40b0 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Fri, 1 May 2026 20:15:53 +0100 Subject: [PATCH 09/25] Add nextCursor to events/list and _meta to event types and occurrences events/list response now shows nextCursor for consistency with base MCP list pagination. Event-type definitions and EventOccurrence both gain optional _meta, matching Tool/Resource/Prompt. --- docs/design-sketch-proposal.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index d3d7543..ade7ab1 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -77,9 +77,11 @@ Servers advertise event support in their capabilities: "deduplicate_window_seconds": { "type": "integer", "default": 0, "description": "Suppress duplicate alerts within this window" } } }, - "payloadSchema": { "..." : "..." } + "payloadSchema": { "..." : "..." }, + "_meta": { "..." : "..." } // optional; same semantics as on Tool/Resource/Prompt } - ] + ], + "nextCursor": "..." // present when more pages are available; same semantics as tools/list etc. } ``` @@ -184,6 +186,7 @@ Each entry in `events[]`, the params of `notifications/events/event` (plus `requ | `timestamp` | string (ISO 8601) | yes | When the event occurred | | `data` | object | yes | Payload conforming to the event type's `payloadSchema` | | `cursor` | string \| null | no | Subscription position after this event (push/webhook only; poll carries cursor at the response level). `null` when the event type does not support replay — see *Cursor Lifecycle*. | +| `_meta` | object | no | Reserved for protocol/extension metadata, consistent with `_meta` on other MCP types. Not governed by `payloadSchema`. | **Notes:** From 197c32b4c57bb28809afa0939214ec7b0a3ea8bd Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Sun, 10 May 2026 16:45:42 +0100 Subject: [PATCH 10/25] Align push correlation with SEP-2575 and rename duration fields to Ms suffix - Push notifications now carry the parent events/stream request id in params._meta["io.modelcontextprotocol/subscriptionId"] (per SEP-2575) instead of params.requestId - Rename nextPollSeconds -> nextPollMs and maxAge -> maxAgeMs (integer milliseconds) to match the MCP duration-field convention; example values and prose updated --- docs/design-sketch-proposal.md | 72 +++++++++++++++++----------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index ade7ab1..59a2ebe 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -8,9 +8,9 @@ Events lets 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. A server declares event types via `events/list` (each with a name, an `inputSchema` for subscription params, a `payloadSchema`, and the delivery modes it supports). The client subscribes with `(name, params)` and receives event occurrences `{eventId, name, timestamp, data, cursor}`. -**Delivery modes.** Three, advertised per event type; none is mandatory. **Poll** (`events/poll`) is request/response: client sends `{name, params, cursor}`, gets back `{events[], cursor, nextPollSeconds}`. **Push** (`events/stream`) is a long-lived request: events arrive as `notifications/events/event`, with a heartbeat carrying the current cursor during quiet periods. **Webhook** (`events/subscribe`) registers an `https` callback URL plus a client-supplied `whsec_` secret; the server POSTs each event there, signed per Standard Webhooks (`webhook-id/-timestamp/-signature` + `X-MCP-Subscription-Id`); subscriptions have a TTL and the client refreshes before `refreshBefore`. The client SDK negotiates the mode and runs the loop; the model only sees arriving events. +**Delivery modes.** Three, advertised per event type; none is mandatory. **Poll** (`events/poll`) is request/response: client sends `{name, params, cursor}`, gets back `{events[], cursor, nextPollMs}`. **Push** (`events/stream`) is a long-lived request: events arrive as `notifications/events/event`, with a heartbeat carrying the current cursor during quiet periods. **Webhook** (`events/subscribe`) registers an `https` callback URL plus a client-supplied `whsec_` secret; the server POSTs each event there, signed per Standard Webhooks (`webhook-id/-timestamp/-signature` + `X-MCP-Subscription-Id`); subscriptions have a TTL and the client refreshes before `refreshBefore`. The client SDK negotiates the mode and runs the loop; the model only sees arriving events. -**Cursors and replay.** A cursor is an opaque server-defined position. The client persists the cursor it receives and passes it back on the next poll/reconnect/refresh; the server resumes from there. Servers whose upstream has no addressable history return `cursor: null` (no replay). `maxAge` bounds replay so a stale cursor doesn't dump an unbounded backlog. `eventId` (preferably the upstream's stable id) is for client-side dedup. Reliable, ordered delivery is achievable when the upstream is durable, but not mandated — `truncated: true` in any response signals events were skipped (stale cursor, `maxAge` floor, or server-side ceiling). +**Cursors and replay.** A cursor is an opaque server-defined position. The client persists the cursor it receives and passes it back on the next poll/reconnect/refresh; the server resumes from there. Servers whose upstream has no addressable history return `cursor: null` (no replay). `maxAgeMs` bounds replay so a stale cursor doesn't dump an unbounded backlog. `eventId` (preferably the upstream's stable id) is for client-side dedup. Reliable, ordered delivery is achievable when the upstream is durable, but not mandated — `truncated: true` in any response signals events were skipped (stale cursor, `maxAgeMs` floor, or server-side ceiling). **State and identity.** The client owns the canonical subscription list; the server holds nothing durable. Poll is per-request; push is connection-scoped; webhook requires an authenticated principal and is TTL'd soft state keyed on `(principal, delivery.url, name, params)` — the response returns a server-derived `id` for routing only. `events/unsubscribe` is eager cleanup; otherwise subscriptions lapse on their own. Authorization is the same MCP principal as for tools; event payloads are untrusted data with the same injection considerations as tool results. @@ -127,9 +127,9 @@ sequenceDiagram participant SDK as Client SDK participant Server as MCP Server - loop every nextPollSeconds + loop every nextPollMs SDK->>Server: events/poll {name, params, cursor} - Server-->>SDK: {events[], cursor, truncated, hasMore, nextPollSeconds} + Server-->>SDK: {events[], cursor, truncated, hasMore, nextPollMs} end Note over SDK: LLM invoked only when
events[] is non-empty ``` @@ -146,7 +146,7 @@ Each `events/poll` request carries one subscription. A client with multiple subs "redact_pii": true }, "cursor": null, // null = start from now - "maxAge": 300, // optional; do not replay events older than this many seconds (see Cursor Lifecycle) + "maxAgeMs": 300000, // optional; do not replay events older than this many milliseconds (see Cursor Lifecycle) "maxEvents": 50 // optional; cap events returned } ``` @@ -171,13 +171,13 @@ Each `events/poll` request carries one subscription. A client with multiple subs "cursor": "historyId_99842", "truncated": false, "hasMore": false, - "nextPollSeconds": 30 + "nextPollMs": 30000 } ``` #### `EventOccurrence` schema -Each entry in `events[]`, the params of `notifications/events/event` (plus `requestId`), and the body of webhook event deliveries is an `EventOccurrence` (webhook also sends non-event control bodies — see *Non-event webhook bodies*): +Each entry in `events[]`, the params of `notifications/events/event`, and the body of webhook event deliveries is an `EventOccurrence` (push notifications additionally carry the stream's subscription id in `_meta` — see *Push-Based Delivery*; webhook also sends non-event control bodies — see *Non-event webhook bodies*): | Field | Type | Required | Description | |---|---|---|---| @@ -192,9 +192,9 @@ Each entry in `events[]`, the params of `notifications/events/event` (plus `requ - `cursor` is opaque to the client. The client stores it and passes it back on the next poll. In the request, `null` means "start from now" — the server returns no events and provides a fresh cursor for subsequent polls. In the response, the server MAY return `cursor: null` for an event type that does not support replay; the client then has nothing to persist and always polls with `null` (see *Cursor Lifecycle*). - `eventId` enables client-side deduplication across polls (e.g., after a crash/restart). It is **server-assigned**: when the upstream source provides a stable event identifier (Stripe `evt_*`, GitHub delivery GUID, Kafka offset, Gmail message ID), the server SHOULD use that value as `eventId` so that the same upstream event surfaced via multiple paths (e.g., webhook emit and poll backfill) carries the same `eventId` and dedup works. The SDK auto-generates an `eventId` only when the author supplies none. -- `maxEvents` is 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 sets `hasMore: true`. The client SHOULD poll again immediately (ignoring `nextPollSeconds`) to drain the backlog. If omitted, the server uses its own default limit. -- `hasMore` indicates whether additional events are available beyond the returned batch. When `true`, the client should poll again immediately with the updated cursor. When `false`, the client waits `nextPollSeconds` before the next poll. -- `nextPollSeconds` allows the server to dynamically adjust polling frequency (e.g., back off when rate-limited upstream, speed up when activity is detected). Ignored when `hasMore` is `true`. Clients SHOULD apply a configurable floor (default 1 second) to guard against a misbehaving server inducing a tight loop. +- `maxEvents` is 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 sets `hasMore: true`. The client SHOULD poll again immediately (ignoring `nextPollMs`) to drain the backlog. If omitted, the server uses its own default limit. +- `hasMore` indicates whether additional events are available beyond the returned batch. When `true`, the client should poll again immediately with the updated cursor. When `false`, the client waits `nextPollMs` before the next poll. +- `nextPollMs` allows the server to dynamically adjust polling frequency (e.g., back off when rate-limited upstream, speed up when activity is detected). Ignored when `hasMore` is `true`. Clients SHOULD apply a configurable floor (default 1000 ms) to guard against a misbehaving server inducing a tight loop. - Empty `events` array 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`, `InvalidParams`, `DeliveryModeUnsupported`) are returned as a standard JSON-RPC error response for the request — there is no partial-success model since each request carries one subscription. @@ -216,9 +216,9 @@ sequenceDiagram SDK->>Server: events/stream {name, params, cursor} activate Server - Server-->>SDK: notifications/events/active {requestId, cursor, truncated} + Server-->>SDK: notifications/events/active {cursor, truncated, _meta.subscriptionId} loop as events occur - Server-->>SDK: notifications/events/event {requestId, eventId, name, timestamp, data, cursor} + Server-->>SDK: notifications/events/event {eventId, name, timestamp, data, cursor, _meta.subscriptionId} end SDK->>Server: notifications/cancelled {requestId} (stdio)
— or connection close (Streamable HTTP) Server-->>SDK: StreamEventsResult (final frame) @@ -238,30 +238,30 @@ sequenceDiagram "name": "email.received", "params": { "from": "*@anthropic.com", "redact_pii": true }, "cursor": null, - "maxAge": 300 + "maxAgeMs": 300000 } } ``` #### 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. +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 the JSON-RPC `id` of the parent `events/stream` request in `params._meta["io.modelcontextprotocol/subscriptionId"]` (per [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)'s correlation convention) so a client with multiple concurrent streams (especially on stdio, where all share one stdout) can route notifications to the correct stream. ```jsonc // Confirmation -{"jsonrpc":"2.0","method":"notifications/events/active","params":{"requestId":1,"cursor":"historyId_99840","truncated":false}} +{"jsonrpc":"2.0","method":"notifications/events/active","params":{"cursor":"historyId_99840","truncated":false,"_meta":{"io.modelcontextprotocol/subscriptionId":1}}} // Events as they occur -{"jsonrpc":"2.0","method":"notifications/events/event","params":{"requestId":1,"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"}} +{"jsonrpc":"2.0","method":"notifications/events/event","params":{"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","_meta":{"io.modelcontextprotocol/subscriptionId":1}}} // Transient per-occurrence error (stream stays open) -{"jsonrpc":"2.0","method":"notifications/events/error","params":{"requestId":1,"error":{"code":-32603,"message":"UpstreamError","data":{"reason":"Gmail API 503"}}}} +{"jsonrpc":"2.0","method":"notifications/events/error","params":{"error":{"code":-32603,"message":"UpstreamError","data":{"reason":"Gmail API 503"}},"_meta":{"io.modelcontextprotocol/subscriptionId":1}}} // Final frame when stream closes (StreamEventsResult) {"jsonrpc":"2.0","id":1,"result":{"_meta":{}}} ``` -`notifications/events/error` reports a recoverable failure (e.g., a single upstream fetch failed); the subscription remains active and the server retries and resumes. Only `notifications/events/terminated` (see *Authorization*) ends the subscription. A gap (e.g., the cursor fell outside the upstream's retention window) is *not* an error — the server sends a fresh `notifications/events/active {requestId, cursor:, truncated:true}` and continues delivering (see *Cursor Lifecycle*). +`notifications/events/error` reports a recoverable failure (e.g., a single upstream fetch failed); the subscription remains active and the server retries and resumes. Only `notifications/events/terminated` (see *Authorization*) ends the subscription. A gap (e.g., the cursor fell outside the upstream's retention window) is *not* an error — the server sends a fresh `notifications/events/active {cursor:, truncated:true, _meta.subscriptionId}` and continues delivering (see *Cursor Lifecycle*). 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. @@ -270,9 +270,9 @@ The `events/stream` response carries only `notifications/events/*` messages. Non #### Lifecycle - **Stream termination.** The `StreamEventsResult` is 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." The heartbeat is `{"jsonrpc":"2.0","method":"notifications/events/heartbeat","params":{"requestId":1,"cursor":"historyId_99850"}}` — `cursor` is the position the server has checked up to, so the client's persisted cursor advances even when no events match (see *Cursor Lifecycle*); it is `null` for event types that do not support replay. On Streamable HTTP this is sent as an SSE `data:` frame; the SSE comment form (`: keepalive`) is not used since it cannot carry cursor state. The server SHOULD send a heartbeat at least every 30 seconds. Clients SHOULD NOT apply their default request timeout to `events/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. +- **Heartbeat.** The server MUST send periodic keepalive messages on the push stream so the client can distinguish "nothing to send" from "connection is dead." The heartbeat is `{"jsonrpc":"2.0","method":"notifications/events/heartbeat","params":{"cursor":"historyId_99850","_meta":{"io.modelcontextprotocol/subscriptionId":1}}}` — `cursor` is the position the server has checked up to, so the client's persisted cursor advances even when no events match (see *Cursor Lifecycle*); it is `null` for event types that do not support replay. On Streamable HTTP this is sent as an SSE `data:` frame; the SSE comment form (`: keepalive`) is not used since it cannot carry cursor state. The server SHOULD send a heartbeat at least every 30 seconds. Clients SHOULD NOT apply their default request timeout to `events/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/cancelled` with the `requestId` matching the `events/stream` request's `id`. In both cases, the server MUST stop delivering events and release any associated resources. On stdio, the server MAY then send the `StreamEventsResult`; 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/stream` requests 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 via `requestId`. Server SDKs MUST exempt `events/stream` from any general request-concurrency cap, since each push subscription is a long-lived request that never completes until cancelled. +- **Concurrent streams.** A client MAY hold multiple concurrent `events/stream` requests 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 via `_meta["io.modelcontextprotocol/subscriptionId"]`. Server SDKs MUST exempt `events/stream` from any general request-concurrency cap, since each push subscription is a long-lived request that never completes until cancelled. - **Reconnection after failure.** If the connection drops (HTTP) or the server stops sending (stdio), the client sends a new `events/stream` with the same subscription and its last-known cursor. #### Cursor Advancement @@ -331,7 +331,7 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau "secret": "whsec_" }, "cursor": null, - "maxAge": 300 + "maxAgeMs": 300000 } } ``` @@ -342,7 +342,7 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau "id": "sub_a3f1c8e2b0d49f7e", // server-derived; stable for this (principal, url, name, params) "refreshBefore": "2026-02-19T16:30:00Z", "cursor": "cursor_start_001", // safe-to-persist watermark (same semantics as payload cursor); advances the client's cursor even if no events arrive before next refresh (see Cursor Lifecycle) - "truncated": false // true if delivery started later than the supplied cursor (retention window, maxAge floor, or server-side ceiling) + "truncated": false // true if delivery started later than the supplied cursor (retention window, maxAgeMs floor, or server-side ceiling) } ``` @@ -529,14 +529,14 @@ Cursors are opaque strings managed by the server. They represent a position in t **Cursor advancement without events.** The client's persisted cursor must advance during quiet periods so it doesn't fall outside the upstream's retention window when nothing is happening. Poll covers this inherently (every response carries `cursor`, including when `events: []`). For push, the heartbeat carries `cursor` (see *Heartbeat* under Push-Based Delivery). For webhook, each `events/subscribe` refresh response carries `cursor`. In all three the value is safe to persist (for webhook it is the same watermark as in delivery payloads, never ahead of unacked events); the client persists it exactly as it would a cursor from a delivered event. -**Bounding replay with `maxAge`.** All three modes accept an optional `maxAge` (integer seconds) alongside `cursor`. When present, the server begins replay from whichever is *later*: the supplied `cursor`, or `now − maxAge`. This lets a client resubscribe with a stale cursor without receiving an unbounded backlog — e.g., a host that was offline for a week can pass its persisted cursor with `maxAge: 300` to get at most the last five minutes. If the floor advances past the cursor, the server SHOULD set `truncated: true` on the first response (poll result / `notifications/events/active` / webhook subscribe response) so the client knows older events were skipped. Servers whose upstream supports time-addressed reads (e.g., Gmail `after:`, Slack `oldest=`, Kafka `offsetsForTimes`) SHOULD seek directly; others MAY honour `maxAge` by replaying from `cursor` and discarding events whose `timestamp` is older than the floor — or, if that would be prohibitively expensive (e.g., a week-old cursor with `maxAge: 300`), MAY instead reset to now and return `truncated: true`, which yields the same client-visible outcome without the wasted scan. Servers MAY also apply their own replay ceiling independent of `maxAge` and MUST signal it via `truncated: true` when they do. `maxAge` is ignored when `cursor` is `null` (null already means "now"; replay from before the subscription's start is out of scope per *What Is NOT in v1*) and for event types that do not support replay. +**Bounding replay with `maxAgeMs`.** All three modes accept an optional `maxAgeMs` (integer milliseconds) alongside `cursor`. When present, the server begins replay from whichever is *later*: the supplied `cursor`, or `now − maxAgeMs`. This lets a client resubscribe with a stale cursor without receiving an unbounded backlog — e.g., a host that was offline for a week can pass its persisted cursor with `maxAgeMs: 300000` to get at most the last five minutes. If the floor advances past the cursor, the server SHOULD set `truncated: true` on the first response (poll result / `notifications/events/active` / webhook subscribe response) so the client knows older events were skipped. Servers whose upstream supports time-addressed reads (e.g., Gmail `after:`, Slack `oldest=`, Kafka `offsetsForTimes`) SHOULD seek directly; others MAY honour `maxAgeMs` by replaying from `cursor` and discarding events whose `timestamp` is older than the floor — or, if that would be prohibitively expensive (e.g., a week-old cursor with `maxAgeMs: 300000`), MAY instead reset to now and return `truncated: true`, which yields the same client-visible outcome without the wasted scan. Servers MAY also apply their own replay ceiling independent of `maxAgeMs` and MUST signal it via `truncated: true` when they do. `maxAgeMs` is ignored when `cursor` is `null` (null already means "now"; replay from before the subscription's start is out of scope per *What Is NOT in v1*) and for event types that do not support replay. -**Gaps and `truncated`.** `truncated: true` is the single signal that the server started delivery from a position later than the cursor the client supplied — i.e., events were skipped. Causes are not distinguished on the wire: the supplied cursor may have fallen outside the upstream's retention window, the `maxAge` floor may have advanced past it, or the server may have applied its own replay ceiling. In all cases the server resets to a position it can serve from, returns that position as the fresh `cursor` alongside `truncated: true`, and continues — the client never reconnects or re-subscribes in response. `truncated: true` always implies a possible gap; clients SHOULD treat it as such (e.g., re-fetch authoritative state via tools if it matters) and persist the fresh cursor. +**Gaps and `truncated`.** `truncated: true` is the single signal that the server started delivery from a position later than the cursor the client supplied — i.e., events were skipped. Causes are not distinguished on the wire: the supplied cursor may have fallen outside the upstream's retention window, the `maxAgeMs` floor may have advanced past it, or the server may have applied its own replay ceiling. In all cases the server resets to a position it can serve from, returns that position as the fresh `cursor` alongside `truncated: true`, and continues — the client never reconnects or re-subscribes in response. `truncated: true` always implies a possible gap; clients SHOULD treat it as such (e.g., re-fetch authoritative state via tools if it matters) and persist the fresh cursor. | Mode | Where `truncated` appears | |---|---| -| Poll | The result body: `{events:[], cursor:, truncated:true, hasMore, nextPollSeconds}`. Never a JSON-RPC error. | -| Push | A `notifications/events/active {requestId, cursor:, truncated:true}` is sent (initially, and again mid-stream if a gap occurs); delivery continues on the same stream. | +| Poll | The result body: `{events:[], cursor:, truncated:true, hasMore, nextPollMs}`. Never a JSON-RPC error. | +| Push | A `notifications/events/active {cursor:, truncated:true, _meta.subscriptionId}` is sent (initially, and again mid-stream if a gap occurs); delivery continues on the same stream. | | Webhook | The subscribe/refresh response: `{id, refreshBefore, cursor:, truncated:true, ...}`. For a gap detected between refreshes, the server POSTs a `{"type":"gap","cursor":""}` control envelope (see *Non-event webhook bodies*) so the client learns of it without waiting for the next refresh. | For event types that do not support replay (`cursor` is always `null`), `truncated` SHOULD be `false` and clients SHOULD ignore it — there is no position to have advanced past. @@ -683,7 +683,7 @@ async def on_unsubscribe(context, params, subscription_id): 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-or-null, eventName, canonicalHash(params))` — on unauthenticated servers all callers share one lease per `(name, params)`. `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: +**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-or-null, eventName, canonicalHash(params))` — on unauthenticated servers all callers share one lease per `(name, params)`. `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 `nextPollMs` so that a well-behaved client never lapses between polls: ```python server = MCPServer( @@ -747,9 +747,9 @@ 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. +**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 1000 ms). 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. +**Push mode:** The SDK opens one `events/stream` per subscription and listens for notifications, routing them by `_meta["io.modelcontextprotocol/subscriptionId"]`. 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 generates a `whsec_` secret, registers it (or its derivation input) with the gateway, then calls `events/subscribe` with `delivery: {url, secret}`. The response provides the server-derived `id` for routing. It runs a background refresh loop, re-calling `events/subscribe` before `refreshBefore` expires. The SDK monitors `deliveryStatus` on each refresh to detect delivery failures. The webhook receiver (which may be a forward proxy) accepts incoming POSTs, looks up the secret by `X-MCP-Subscription-Id`, verifies the Standard Webhooks signature, checks timestamp freshness, and routes events to the appropriate subscription iterator. On `sub.cancel()`, the SDK calls `events/unsubscribe` for eager cleanup. @@ -789,13 +789,13 @@ This reduces PII exposure in event infrastructure (logs, queues, buffers) and li ``` // Push -data: {"jsonrpc":"2.0","method":"notifications/events/terminated","params":{"requestId":1,"error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}}}} +data: {"jsonrpc":"2.0","method":"notifications/events/terminated","params":{"error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}},"_meta":{"io.modelcontextprotocol/subscriptionId":1}}} // Webhook (POST body) {"type":"terminated","error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}}} ``` -`notifications/events/terminated` has the shape `{requestId: string|integer, 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. +`notifications/events/terminated` has the shape `{error: {code: integer, message: string, data?: object}, _meta: {"io.modelcontextprotocol/subscriptionId": string|integer}}` — 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. @@ -806,7 +806,7 @@ The spec does not mandate specific governance mechanisms but is designed to enab - **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, call `events/unsubscribe` for immediate termination, or simply stop refreshing and the subscription will expire at TTL. -- **Rate limiting:** `nextPollSeconds` allows servers to control polling frequency dynamically. Clients SHOULD respect it. Enterprise deployments may impose additional rate limits. +- **Rate limiting:** `nextPollMs` allows servers to control polling frequency dynamically. Clients SHOULD respect it. Enterprise deployments may impose additional rate limits. ## Relationship to Existing Primitives @@ -869,7 +869,7 @@ In practice, the dominant bottleneck in an MCP event pipeline is LLM inference 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, `nextPollSeconds` lets the server throttle the client explicitly. +- **Server-controlled push frequency.** For push mode, the server controls how often it checks upstream sources and delivers events. For poll mode, `nextPollMs` lets 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. @@ -884,17 +884,17 @@ These are the choices where reasonable alternatives existed; recorded so reviewe |---|---|---| | Mandatory delivery mode | None — servers advertise any non-empty subset of poll/push/webhook | Universal compatibility would be nice, but mandating any single mode places an unacceptable burden on implementers (poll needs a history or buffer; push needs a held connection; webhook needs outbound HTTP + SSRF hardening). The extension is already opt-in. May revisit if deployments converge on a common subset in practice. | | Webhook signature scheme | Adopt Standard Webhooks as a profile (`webhook-id`/`-timestamp`/`-signature`, `v1,` HMAC, `whsec_` secrets, multi-sig rotation) | Gets off-the-shelf verifier libraries in most languages and a defined asymmetric (`v1a,`) path for free. Trade-off is a dependency on an external community spec, but it's small, stable, pinnable, and not a one-way door. | -| Batched poll/stream | One subscription per `events/poll`/`events/stream` request | Batching is a transport-level concern; HTTP/2 multiplexing makes per-subscription requests cheap. Avoids the protocol complexity batching introduces (coalescing heterogeneous `nextPollSeconds`, partial-failure result shapes, per-entry error routing, duplicate-id handling). Clients with many subscriptions on HTTP/1.1 should expect to coalesce at the transport, not the protocol. | +| Batched poll/stream | One subscription per `events/poll`/`events/stream` request | Batching is a transport-level concern; HTTP/2 multiplexing makes per-subscription requests cheap. Avoids the protocol complexity batching introduces (coalescing heterogeneous `nextPollMs`, partial-failure result shapes, per-entry error routing, duplicate-id handling). Clients with many subscriptions on HTTP/1.1 should expect to coalesce at the transport, not the protocol. | | Webhook secret generation | Client-supplied, REQUIRED, per subscription (`whsec_` + 24–64 B) | Avoids the post-subscribe write-back race of server-generated per-sub secrets and the out-of-band registration step of per-endpoint secrets. Crucially, HMAC then proves "this delivery is for a subscription *this endpoint* originated," not merely "this came from the server" — closing third-party-target abuse without a mandatory challenge. | | Multimedia / high-throughput streams | Out of scope | Streaming media has fundamentally different transport, framing, and back-pressure requirements. Designing for human/automation-scale event rates keeps the SDK surface tractable; a media primitive would be a separate proposal. | | Reliable / ordered delivery | Supported (via opaque cursors + `eventId` dedup), not mandated | Genuine use cases need it, so the cursor abstraction makes at-least-once-with-replay achievable when the upstream is durable. But many upstreams are push-only with no history; mandating reliability would force them to fake it. Servers may return `cursor: null` to opt out. | | Webhook subscription identity | `(principal, delivery.url, name, params)`; webhook requires auth; server-derived `id` for routing only | The tuple is everything that semantically defines a subscription, so subscribe is naturally idempotent and there is nothing for the client to generate or persist. The derived `id` is a non-capability routing handle. Webhook requires an authenticated principal so the tuple is not guessable by other tenants; without it, anyone could unsubscribe or rotate another tenant's secret. Server-generated and client-generated `id` schemes were considered; both add state or entropy obligations the tuple makes unnecessary. | | Server-side subscription durability | None — push is connection-scoped, webhook is TTL'd soft state, poll is per-request | Clients hold the canonical subscription list and reconstruct server state by re-subscribing. A server crash never orphans a client; a vanished client never leaks server resources. Trade-off: no server-side "list all subscriptions" without per-principal enumeration. | | Webhook callback TLS | `https` is MUST | Both event payloads and the `delivery.secret` (in the subscribe request) would otherwise transit in cleartext. No legitimate remote-server deployment lacks TLS in 2026. | -| Replay bound | Time-floor `maxAge` only; no count bound | Time-anchored start is the universal primitive across replayable systems (Kinesis `AT_TIMESTAMP`, JetStream `by_start_time`, Kafka `offsetsForTimes`, RabbitMQ/Pulsar/PubSub seek-to-time). Count-bounded subscribe ("newest N") requires backward-seek that many upstreams can't do cheaply and has ambiguous which-end semantics. | +| Replay bound | Time-floor `maxAgeMs` only; no count bound | Time-anchored start is the universal primitive across replayable systems (Kinesis `AT_TIMESTAMP`, JetStream `by_start_time`, Kafka `offsetsForTimes`, RabbitMQ/Pulsar/PubSub seek-to-time). Count-bounded subscribe ("newest N") requires backward-seek that many upstreams can't do cheaply and has ambiguous which-end semantics. | | Cursor advancement during quiet periods | Heartbeat carries cursor (push); refresh response carries cursor (webhook); poll already returns cursor on empty results | Without this, a long quiet stretch leaves the client holding a cursor older than the upstream's retention window, yielding a spurious `truncated: true` even though nothing happened. Reuses existing periodic messages; no new traffic. | | `eventId` source | Server-assigned; SHOULD be the upstream's stable identifier | Dual-path delivery (e.g., webhook emit + poll backfill) of the same upstream event must collapse under client-side dedup. SDK auto-generates only when the author supplies none. | -| Protocol-level flow control | None in v1 | The dominant bottleneck is LLM inference (seconds–minutes per event), not delivery. TCP back-pressure, `nextPollSeconds`, and client-side prioritization are sufficient for the target event rates. Credit-based control can be added later without breaking changes. | +| Protocol-level flow control | None in v1 | The dominant bottleneck is LLM inference (seconds–minutes per event), not delivery. TCP back-pressure, `nextPollMs`, and client-side prioritization are sufficient for the target event rates. Credit-based control can be added later without breaking changes. | | New primitive vs extending `resources/subscribe` | New `events/*` primitive | Resources are content-addressed (URI → representation) without parameters or cursors; bolting filter params, replay, and three delivery modes onto them would distort that model more than a parallel primitive costs. Folding `resources/subscribe` into events later is left as an open question. | ## Appendix: End-to-End Example — GitHub MCP Server (Webhook Delivery) From 754be6ce47ec22873c94565eb8f2d90faa1a7c89 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Thu, 14 May 2026 18:36:25 +0100 Subject: [PATCH 11/25] Add rationale for three delivery modes to Key Design Decisions Records why poll, push, and webhook all exist as protocol-level modes rather than picking one: the deployment topologies are disjoint and no mode subsumes another. Acknowledges the departure from the usual one-way-to-do-it stance and the spec/SDK cost it carries, and notes the rejected alternative (one mode + proxy synthesis) and why. --- docs/design-sketch-proposal.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index 59a2ebe..a913216 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -882,6 +882,7 @@ These are the choices where reasonable alternatives existed; recorded so reviewe | Decision | Choice | Rationale | |---|---|---| +| Number of delivery modes | Three — poll, push, webhook | This goes against MCP's usual stance of offering one way to do a thing, and the cost is real: roughly 3× spec and SDK surface, two cursor placements, two subscription lifecycles. We pay it because the deployment topologies events must reach are disjoint, and dropping any mode strands a real population. **Poll** is the floor: it works for stateless/serverless servers with no outbound HTTP and no long-lived connections, and for clients behind NAT with no public endpoint — but at interval-bounded latency. **Push** gets low latency for stdio servers, local agents, and HTTP clients that can hold a stream open — but a server must keep a connection per subscription, which scale-to-zero and connection-capped deployments cannot. **Webhook** gets low latency without a held connection — but requires the server to make outbound HTTP and the client (or a proxy acting for it) to expose a reachable endpoint. No mode subsumes another: a serverless server can't push, a NAT'd client can't receive webhooks, a battery- or cost-sensitive client shouldn't poll. The alternative — define one mode and let proxies synthesize the rest — was rejected because it forces an always-on, trusted intermediary (holding cursors and webhook secrets) into every deployment that doesn't fit the chosen mode, which is most of them. May revisit if real deployments converge on a subset. | | Mandatory delivery mode | None — servers advertise any non-empty subset of poll/push/webhook | Universal compatibility would be nice, but mandating any single mode places an unacceptable burden on implementers (poll needs a history or buffer; push needs a held connection; webhook needs outbound HTTP + SSRF hardening). The extension is already opt-in. May revisit if deployments converge on a common subset in practice. | | Webhook signature scheme | Adopt Standard Webhooks as a profile (`webhook-id`/`-timestamp`/`-signature`, `v1,` HMAC, `whsec_` secrets, multi-sig rotation) | Gets off-the-shelf verifier libraries in most languages and a defined asymmetric (`v1a,`) path for free. Trade-off is a dependency on an external community spec, but it's small, stable, pinnable, and not a one-way door. | | Batched poll/stream | One subscription per `events/poll`/`events/stream` request | Batching is a transport-level concern; HTTP/2 multiplexing makes per-subscription requests cheap. Avoids the protocol complexity batching introduces (coalescing heterogeneous `nextPollMs`, partial-failure result shapes, per-entry error routing, duplicate-id handling). Clients with many subscriptions on HTTP/1.1 should expect to coalesce at the transport, not the protocol. | From 9f9cdce8b14c43b4546cf217269519ac3ac4569b Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Fri, 22 May 2026 13:07:23 +0100 Subject: [PATCH 12/25] Resolve endpoint verification and add Security threat model - Replace Open Question 6 with normative endpoint-verification rules: a mandatory intent challenge (or allowlist) before activating webhook delivery, cached per (principal, url); add -32014 EndpointVerificationFailed and fill the verification control-envelope row. - Add optional asymmetric server signing (Standard Webhooks v1a,) for known-server allowlists. The webhook-signing key is discovered from an origin the client already authenticates: inline webhooks.signingKeys on the SEP-2127 server card if it lands first, otherwise a standalone /.well-known/mcp-webhook-jwks.json document (one chosen at SEP-review time). - Correct the webhook-secret rationale (HMAC stops deception, not flooding) and add Key Design Decisions rows for endpoint verification and server identity. - Add a Threat Model summary table to Security Considerations. --- docs/design-sketch-proposal.md | 41 ++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index 59a2ebe..f561c8b 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -111,11 +111,12 @@ There are three delivery modes with different subscription mechanisms: | `-32011` | `EventNotFound` | Unknown event name | | `-32012` | `Unauthorized` | User lacks permission for this event/params combination | | `-32013` | `TooManySubscriptions` | Server-imposed subscription limit reached | +| `-32014` | `EndpointVerificationFailed` | Callback endpoint was reachable but failed the verification handshake — did not echo the challenge (webhook mode only) | | `-32015` | `InvalidCallbackUrl` | Webhook URL is unreachable or rejected by the server (webhook mode only) | | `-32016` | `SubscriptionNotFound` | No subscription matches the supplied key for `events/unsubscribe` (webhook mode only) | | `-32017` | `DeliveryModeUnsupported` | Event type does not support the requested delivery mode | -These codes are drawn from `-32011..-32017` (`-32014` is reserved/unused) to avoid collision with base MCP error codes (e.g., `-32002 ResourceNotFound`). +These codes are drawn from `-32011..-32017` to avoid collision with base MCP error codes (e.g., `-32002 ResourceNotFound`). ### Poll-Based Delivery @@ -423,7 +424,7 @@ In addition to `EventOccurrence` bodies, the server POSTs control envelopes to t |---|---|---| | `gap` | `{"type":"gap","cursor":""}` | A gap is detected between refreshes (see *Gaps and `truncated`*). The client persists `cursor` and treats it as `truncated: true`. | | `terminated` | `{"type":"terminated","error":{"code":...,"message":...,"data":...}}` | The subscription has ended (e.g., authorization revoked). The subscription no longer exists server-side. | -| `verification` | `{"type":"verification","challenge":""}` | *(Pending — see Open Question 6.)* | +| `verification` | `{"type":"verification","challenge":""}` | Sent before activating delivery to an unverified `(principal, url)` (see *Webhook Security → Endpoint verification*). The endpoint echoes `challenge` in a `2xx` body to prove intent; the server activates delivery only on a constant-time match. | #### Webhook Delivery Status @@ -466,7 +467,11 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre **SSRF prevention.** The server MUST validate callback URLs. Servers SHOULD reject URLs whose resolved IP is not globally routable per the [IANA IPv4](https://www.iana.org/assignments/iana-ipv4-special-registry/) and [IPv6](https://www.iana.org/assignments/iana-ipv6-special-registry/) 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.** *(Open — see Open Question 6.)* Whether the server performs an ownership challenge before activating delivery, what that challenge carries, and whether it is MUST or SHOULD given client-supplied HMAC, are not yet settled. The interaction with optional asymmetric server signing (sender identity for gateway allowlists) is part of the same decision. +**Endpoint verification (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **either**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**. A reachable endpoint that fails to echo yields `-32014 EndpointVerificationFailed`; an unreachable one yields `-32015 InvalidCallbackUrl`. + +Verification is cached per `(principal, url)`: a successful handshake or allowlist hit covers that principal's subscriptions to that URL across refreshes and `params`, so varying `params` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. + +**Server identity (optional).** A server MAY additionally sign deliveries and verification POSTs with an asymmetric key — Standard Webhooks `v1a,` (ed25519), appended alongside the `v1,` HMAC — so a gateway can verify *which* server sent a delivery, enforce a known-server allowlist, and retain protection if an HMAC secret leaks. The verifying public key MUST be discovered from an origin the client already authenticates, and never from the challenge or delivery body, which the attacker controls (that would be trust-on-first-use). The discovery mechanism is a SEP-review-time decision contingent on [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) (server cards), still draft: if server cards are published before this extension, the key is carried inline on the server's card (provisionally a `webhooks.signingKeys` member holding an ed25519 JWK set); otherwise it is published as a standalone JWKS document at a well-known path on the server's origin, `/.well-known/mcp-webhook-jwks.json`. Exactly one of these will be normative in the final SEP — they are not two options offered to implementers. Either way the key is fetched from the same origin the gateway connects to for MCP, and is distinct from the OAuth `jwks_uri` ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), which carries the authorization server's token-signing keys, not the MCP server's webhook-signing key. Keys carry a `kid`; rotation publishes the new key alongside the old. Endpoints that do not enforce server identity ignore `v1a,`. **TLS requirement.** Callback URLs MUST use `https://`. Servers MUST reject `events/subscribe` with a non-`https` `delivery.url` (`-32015 InvalidCallbackUrl`). Event payloads and the `delivery.secret` (sent in the subscribe request) would otherwise transit in cleartext. @@ -759,6 +764,27 @@ When an event arrives (via any delivery mode), the client SDK surfaces it to the ## Security Considerations +### Threat Model + +The threats this extension is designed to defend against, and the mechanism addressing each. Payload and authorization threats apply to all delivery modes; the rest are specific to webhook mode, the only mode that makes outbound requests to client-supplied URLs. Details are in the referenced sections. + +| Threat | How it's addressed | +|---|---| +| Prompt injection via event payloads | Payloads are untrusted data — clients sanitize/sandbox before the LLM (as with tool results); servers keep payloads minimal to shrink the injection surface. *(Event Payloads Are Untrusted Data; Payload Minimality)* | +| PII / sensitive-data exposure in event infrastructure (logs, queues, buffers) | Payload minimality — deliver only triage fields (e.g. sender, subject, id) and fetch full content via tools on demand. *(Payload Minimality)* | +| Unauthorized subscription, or continued delivery after access is revoked | Subscribe-time permission check (`-32012 Unauthorized`); periodic delivery-time re-verification; a `terminated` envelope ends the subscription when access is lost. *(Authorization)* | +| Treating event receipt as authority to act | Action-time authorization — the agent's response to an event goes through normal MCP authorization; receipt grants no privilege. *(Authorization)* | +| SSRF / DNS rebinding (server coerced into requesting internal hosts) | Delivery-time IP validation against the IANA special-purpose registries, connecting to the validated IP with the original Host/SNI, and no redirects; optional callback-URL allowlist. *(Webhook Security → SSRF prevention)* | +| Reflection / amplification DDoS (attacker subscribes a victim's URL) | Mandatory intent challenge (or allowlist) before activating delivery, cached per `(principal, url)`; subscribe and per-destination rate limits. *(Endpoint verification)* | +| Forged / spoofed deliveries (endpoint deceived into processing fake events) | Per-subscription HMAC (Standard Webhooks `v1,`); receiver verifies the signature before processing. *(Signature scheme)* | +| Server impersonation (endpoint cannot tell which server sent a delivery) | Optional asymmetric signing (`v1a,`) with the public key anchored to the server's trusted origin; gateways allowlist known servers. *(Server identity)* | +| Replay of captured deliveries | `webhook-timestamp` freshness window (reject > 5 min) and `eventId` deduplication; single-use, short-lived challenge nonce. *(Signature scheme; Endpoint verification)* | +| Signing-secret leakage | Per-subscription secrets limit blast radius; client-driven rotation with a dual-sign grace window; optional asymmetric signing as defence-in-depth. *(Secret rotation; Server identity)* | +| Eavesdropping in transit (payloads and `delivery.secret`) | TLS required — `https` callback URLs only; non-`https` subscribe is rejected. *(TLS requirement)* | +| Cross-tenant subscription tampering (guessing the tuple to unsubscribe or rotate another tenant's secret) | Webhook requires an authenticated principal; subscription identity includes `principal`; the derived `id` is a non-capability routing handle. *(Subscription Identity)* | +| Response oracle / internal-service probing via error detail | `deliveryStatus.lastError` is a fixed category string only — never raw endpoint bodies, headers, or status lines. *(Webhook Delivery Status)* | +| Server-side resource exhaustion (subscription flooding) | `-32013 TooManySubscriptions` limits; webhook subscriptions are TTL'd soft state that expire without refresh. *(Error Codes)* | + ### 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: @@ -839,11 +865,6 @@ The spec does not mandate specific governance mechanisms but is designed to enab 5. **Should MCP task state changes be exposed as events?** Tasks are MCP's long-running-operation primitive; clients currently learn of state transitions via push notifications on a live connection. Exposing a reserved `mcp.task.updated` event type (params `{taskId}`, payload `{status, progress?, result?}`) would let a client that started a task and disconnected receive the completion via poll or webhook — useful for "kick off a long job, close the laptop, get notified when done." Same considerations as question 2: reserved `mcp.*` prefix, migration path, and whether one mechanism subsuming another is worth the coupling. -6. **Endpoint verification and server-identity signing — what is required, and how do they relate?** Two intertwined sub-questions: - - *Ownership challenge.* With client-supplied HMAC secrets, a delivery to an endpoint that didn't originate the secret already fails verification, so a nonce-echo challenge is arguably defence-in-depth (fail-fast on bad URLs; bound spam to one POST) rather than the load-bearing control. Options: MUST (prior text), SHOULD, or omit. If kept, the challenge is an HMAC-signed `{"type":"verification","id":...,"challenge":...}` POST; the endpoint echoes the token. - - *Asymmetric server signing.* Servers MAY additionally sign deliveries with an ed25519 key (Standard Webhooks `v1a,`), with the public key published via JWKS at the server's origin (potentially via SEP-2127 server cards). This lets a gateway verify *which server* sent a delivery and enforce a server allowlist, and provides defence-in-depth if an HMAC secret leaks. Key distribution must be anchored to an origin the gateway already trusts — a key or JWKS URL carried in the challenge itself is trust-on-first-use and can be attacker-supplied. - These need to be resolved together: the answer to "is the challenge MUST" depends on whether asymmetric signing is expected to be common, and both depend on what threat the gateway is defending against (third-party-target abuse vs server impersonation vs leaked secret). - ## 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. @@ -885,7 +906,9 @@ These are the choices where reasonable alternatives existed; recorded so reviewe | Mandatory delivery mode | None — servers advertise any non-empty subset of poll/push/webhook | Universal compatibility would be nice, but mandating any single mode places an unacceptable burden on implementers (poll needs a history or buffer; push needs a held connection; webhook needs outbound HTTP + SSRF hardening). The extension is already opt-in. May revisit if deployments converge on a common subset in practice. | | Webhook signature scheme | Adopt Standard Webhooks as a profile (`webhook-id`/`-timestamp`/`-signature`, `v1,` HMAC, `whsec_` secrets, multi-sig rotation) | Gets off-the-shelf verifier libraries in most languages and a defined asymmetric (`v1a,`) path for free. Trade-off is a dependency on an external community spec, but it's small, stable, pinnable, and not a one-way door. | | Batched poll/stream | One subscription per `events/poll`/`events/stream` request | Batching is a transport-level concern; HTTP/2 multiplexing makes per-subscription requests cheap. Avoids the protocol complexity batching introduces (coalescing heterogeneous `nextPollMs`, partial-failure result shapes, per-entry error routing, duplicate-id handling). Clients with many subscriptions on HTTP/1.1 should expect to coalesce at the transport, not the protocol. | -| Webhook secret generation | Client-supplied, REQUIRED, per subscription (`whsec_` + 24–64 B) | Avoids the post-subscribe write-back race of server-generated per-sub secrets and the out-of-band registration step of per-endpoint secrets. Crucially, HMAC then proves "this delivery is for a subscription *this endpoint* originated," not merely "this came from the server" — closing third-party-target abuse without a mandatory challenge. | +| Webhook secret generation | Client-supplied, REQUIRED, per subscription (`whsec_` + 24–64 B) | Avoids the post-subscribe write-back race of server-generated per-sub secrets and the out-of-band registration step of per-endpoint secrets. HMAC proves "this delivery is for a subscription *this endpoint* originated" (not merely "this came from the server"), which stops third-party *deception*; third-party *flooding* is closed separately by the mandatory intent challenge (see *Endpoint verification*). | +| Endpoint verification | Mandatory intent challenge (echo a signed nonce) or allowlist before activating delivery; cached per `(principal, url)` | HMAC stops deception but not flooding: an attacker can point a subscription at a victim's URL and the victim still receives every POST. A consent handshake (the WebSub/SNS/EventGrid pattern) bounds attacker-induced traffic to one POST per `(principal, victim-url)`; per-`(principal, url)` scoping stops one principal's verification waiving the challenge for another. | +| Server identity to the endpoint | Optional asymmetric signing (Standard Webhooks `v1a,`); verifying key discovered from an origin the client already authenticates — the SEP-2127 server card (inline `webhooks.signingKeys` JWK set) if it lands before this extension, otherwise a standalone `/.well-known/mcp-webhook-jwks.json` document (one chosen at SEP-review time, not both offered) | Symmetric HMAC can't prove *which* server sent a delivery (the secret is shared with the endpoint), so gateways that allowlist known servers need asymmetric signatures. Optional to avoid imposing key management on simple/serverless servers. The key must come from an origin the client already authenticates, not the delivery, else the allowlist is trust-on-first-use. | | Multimedia / high-throughput streams | Out of scope | Streaming media has fundamentally different transport, framing, and back-pressure requirements. Designing for human/automation-scale event rates keeps the SDK surface tractable; a media primitive would be a separate proposal. | | Reliable / ordered delivery | Supported (via opaque cursors + `eventId` dedup), not mandated | Genuine use cases need it, so the cursor abstraction makes at-least-once-with-replay achievable when the upstream is durable. But many upstreams are push-only with no history; mandating reliability would force them to fake it. Servers may return `cursor: null` to opt out. | | Webhook subscription identity | `(principal, delivery.url, name, params)`; webhook requires auth; server-derived `id` for routing only | The tuple is everything that semantically defines a subscription, so subscribe is naturally idempotent and there is nothing for the client to generate or persist. The derived `id` is a non-capability routing handle. Webhook requires an authenticated principal so the tuple is not guessable by other tenants; without it, anyone could unsubscribe or rotate another tenant's secret. Server-generated and client-generated `id` schemes were considered; both add state or entropy obligations the tuple makes unnecessary. | From 567be29eb90a0ac2af8f5d8f52e4fe268fce9cbf Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Fri, 22 May 2026 13:45:50 +0100 Subject: [PATCH 13/25] Consolidate event error codes into reusable general-purpose set Replace the seven event-specific error codes (-32011..-32017) with five general-purpose codes that carry their specifics in a typed `data` payload: NotFound, Forbidden, ResourceExhausted, Unsupported, CallbackEndpointError Bad or non-https callback URLs and malformed secrets now fold into the standard InvalidParams (-32602). The remaining codes are contiguous (-32011..-32015) and named for reuse across the protocol rather than scoped to events; clients still distinguish every case they act on by code, by method, or by a typed `data` discriminator. Also corrects the stale -32002 ResourceNotFound reference (no longer present in the schema) and records the trade-off in Key Design Decisions. --- docs/design-sketch-proposal.md | 41 +++++++++++++++++----------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index f561c8b..39215a3 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -107,16 +107,14 @@ There are three delivery modes with different subscription mechanisms: | 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` | `EndpointVerificationFailed` | Callback endpoint was reachable but failed the verification handshake — did not echo the challenge (webhook mode only) | -| `-32015` | `InvalidCallbackUrl` | Webhook URL is unreachable or rejected by the server (webhook mode only) | -| `-32016` | `SubscriptionNotFound` | No subscription matches the supplied key for `events/unsubscribe` (webhook mode only) | -| `-32017` | `DeliveryModeUnsupported` | Event type does not support the requested delivery mode | +| `-32602` | `InvalidParams` | Request is statically invalid — params don't match the event's inputSchema, the callback `delivery.url` is malformed or non-`https`, or `delivery.secret` is not a valid `whsec_` value (standard JSON-RPC invalid params). | +| `-32011` | `NotFound` | A referenced entity does not exist — an unknown event name, or no subscription matching the key on `events/unsubscribe`. The method implies which; `data.kind` (`"event"` \| `"subscription"`) MAY disambiguate. | +| `-32012` | `Forbidden` | The authenticated principal is not permitted for this event/params combination, or its access was revoked. | +| `-32013` | `ResourceExhausted` | A server-imposed limit or quota was reached. `data.limit` names it (e.g. `"subscriptions"`); `data.max` MAY give the ceiling. | +| `-32014` | `Unsupported` | The request is well-formed but a requested capability or option is not supported here — e.g. a delivery mode the event type does not offer. `data` identifies it (e.g. `{ "feature": "deliveryMode", "value": "push" }`). | +| `-32015` | `CallbackEndpointError` | A client-supplied callback endpoint failed verification or could not be reached (webhook mode only). `data.reason` is one of the `lastError` categories (`connection_refused`, `timeout`, `tls_error`, `http_4xx`, `http_5xx`, `challenge_failed`). | -These codes are drawn from `-32011..-32017` to avoid collision with base MCP error codes (e.g., `-32002 ResourceNotFound`). +These are **general-purpose** codes — named for reuse across MCP, not scoped to events — carried in the JSON-RPC implementation-defined server range `[-32000, -32099]` alongside MCP's existing `-32003`/`-32004`/`-32042`. Each conveys its specifics through a typed `data` payload (the same pattern as `UnsupportedProtocolVersionError.data`), so one code spans a family of conditions without minting new numbers, while clients still distinguish every case they branch on (by code, by method, or by a typed `data` discriminator). They are intended as candidates for promotion to the base MCP error registry; until then this extension defines them and future SEPs SHOULD reuse them rather than introduce overlapping codes. (`-32002 ResourceNotFound`, cited in earlier drafts, is not present in the current MCP schema; `NotFound` is its general replacement.) ### Poll-Based Delivery @@ -198,7 +196,7 @@ Each entry in `events[]`, the params of `notifications/events/event`, and the bo - `nextPollMs` allows the server to dynamically adjust polling frequency (e.g., back off when rate-limited upstream, speed up when activity is detected). Ignored when `hasMore` is `true`. Clients SHOULD apply a configurable floor (default 1000 ms) to guard against a misbehaving server inducing a tight loop. - Empty `events` array 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`, `InvalidParams`, `DeliveryModeUnsupported`) are returned as a standard JSON-RPC error response for the request — there is no partial-success model since each request carries one subscription. +- Errors (`NotFound`, `Forbidden`, `InvalidParams`, `Unsupported`) 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 @@ -246,7 +244,7 @@ sequenceDiagram #### 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 the JSON-RPC `id` of the parent `events/stream` request in `params._meta["io.modelcontextprotocol/subscriptionId"]` (per [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)'s correlation convention) so a client with multiple concurrent streams (especially on stdio, where all share one stdout) can route notifications to the correct stream. +If the subscription is invalid (`NotFound`, `Forbidden`, `InvalidParams`, `Unsupported`), 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 the JSON-RPC `id` of the parent `events/stream` request in `params._meta["io.modelcontextprotocol/subscriptionId"]` (per [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)'s correlation convention) so a client with multiple concurrent streams (especially on stdio, where all share one stdout) can route notifications to the correct stream. ```jsonc // Confirmation @@ -362,7 +360,7 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau 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. -**Authentication required.** `events/subscribe` and `events/unsubscribe` MUST be called with an authenticated principal; servers MUST reject unauthenticated calls with `-32012 Unauthorized`. Without a principal in the key, the tuple `(delivery.url, name, params)` is guessable and any caller could unsubscribe or rotate another tenant's secret. Unauthenticated MCP servers may offer poll and push, but not webhook. +**Authentication required.** `events/subscribe` and `events/unsubscribe` MUST be called with an authenticated principal; servers MUST reject calls without an authorized principal with `-32012 Forbidden`. Without a principal in the key, the tuple `(delivery.url, name, params)` is guessable and any caller could unsubscribe or rotate another tenant's secret. Unauthenticated MCP servers may offer poll and push, but not webhook. **Key composition.** The subscription key is `(principal, delivery.url, name, params)`, where `principal` is the server's canonical identifier for the authenticated subject (e.g., OAuth `sub`, API key ID, service-account name). `params` is compared by canonical-JSON equality. There is no client-generated `id` — a subscription is fully determined by what it listens for, where it delivers, and who asked. @@ -461,19 +459,19 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre } ``` -`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. +`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. The same categories also appear as `data.reason` on a `-32015 CallbackEndpointError` returned synchronously from `events/subscribe`. #### 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](https://www.iana.org/assignments/iana-ipv4-special-registry/) and [IPv6](https://www.iana.org/assignments/iana-ipv6-special-registry/) 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 (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **either**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**. A reachable endpoint that fails to echo yields `-32014 EndpointVerificationFailed`; an unreachable one yields `-32015 InvalidCallbackUrl`. +**Endpoint verification (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **either**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**. A reachable endpoint that fails to echo yields `-32015 CallbackEndpointError` with `data.reason: "challenge_failed"`; an unreachable one yields the same code with the relevant connection-failure category (`connection_refused`, `timeout`, or `tls_error`). Verification is cached per `(principal, url)`: a successful handshake or allowlist hit covers that principal's subscriptions to that URL across refreshes and `params`, so varying `params` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. **Server identity (optional).** A server MAY additionally sign deliveries and verification POSTs with an asymmetric key — Standard Webhooks `v1a,` (ed25519), appended alongside the `v1,` HMAC — so a gateway can verify *which* server sent a delivery, enforce a known-server allowlist, and retain protection if an HMAC secret leaks. The verifying public key MUST be discovered from an origin the client already authenticates, and never from the challenge or delivery body, which the attacker controls (that would be trust-on-first-use). The discovery mechanism is a SEP-review-time decision contingent on [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) (server cards), still draft: if server cards are published before this extension, the key is carried inline on the server's card (provisionally a `webhooks.signingKeys` member holding an ed25519 JWK set); otherwise it is published as a standalone JWKS document at a well-known path on the server's origin, `/.well-known/mcp-webhook-jwks.json`. Exactly one of these will be normative in the final SEP — they are not two options offered to implementers. Either way the key is fetched from the same origin the gateway connects to for MCP, and is distinct from the OAuth `jwks_uri` ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), which carries the authorization server's token-signing keys, not the MCP server's webhook-signing key. Keys carry a `kid`; rotation publishes the new key alongside the old. Endpoints that do not enforce server identity ignore `v1a,`. -**TLS requirement.** Callback URLs MUST use `https://`. Servers MUST reject `events/subscribe` with a non-`https` `delivery.url` (`-32015 InvalidCallbackUrl`). Event payloads and the `delivery.secret` (sent in the subscribe request) would otherwise transit in cleartext. +**TLS requirement.** Callback URLs MUST use `https://`. Servers MUST reject `events/subscribe` with a non-`https` `delivery.url` (`-32602 InvalidParams`). Event payloads and the `delivery.secret` (sent in the subscribe request) would otherwise transit in cleartext. **Signature scheme (Standard Webhooks profile).** MCP webhook delivery is a profile of [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md). Every delivery MUST include `webhook-id` (the `eventId` for event deliveries; `msg__` for control envelopes), `webhook-timestamp` (Unix seconds), and `webhook-signature`. The signature is `HMAC-SHA256(secret, webhook-id + "." + webhook-timestamp + "." + body)` encoded as base64 with a `v1,` prefix. In this formula, `body` is the raw HTTP request body bytes exactly as received, before any parsing or re-serialization; the `webhook-id + "." + webhook-timestamp + "."` prefix is UTF-8; and `secret` is the base64-decoded bytes of the value after the `whsec_` prefix. Receivers MUST compute the HMAC over the raw body, never over a re-serialized JSON object. The header MAY contain multiple space-delimited signatures (`v1, v1,`) during secret rotation; the receiver accepts if any verifies. Each retry attempt MUST regenerate the timestamp and signature so retries are not rejected by the receiver's freshness window. The receiver SHOULD reject deliveries where `webhook-timestamp` is more than 5 minutes old. Off-the-shelf Standard Webhooks verifiers (e.g., Svix libraries) work without modification. @@ -686,7 +684,7 @@ async def on_unsubscribe(context, params, subscription_id): 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. +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 the subscription limit (`ResourceExhausted`) 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-or-null, eventName, canonicalHash(params))` — on unauthenticated servers all callers share one lease per `(name, params)`. `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 `nextPollMs` so that a well-behaved client never lapses between polls: @@ -772,7 +770,7 @@ The threats this extension is designed to defend against, and the mechanism addr |---|---| | Prompt injection via event payloads | Payloads are untrusted data — clients sanitize/sandbox before the LLM (as with tool results); servers keep payloads minimal to shrink the injection surface. *(Event Payloads Are Untrusted Data; Payload Minimality)* | | PII / sensitive-data exposure in event infrastructure (logs, queues, buffers) | Payload minimality — deliver only triage fields (e.g. sender, subject, id) and fetch full content via tools on demand. *(Payload Minimality)* | -| Unauthorized subscription, or continued delivery after access is revoked | Subscribe-time permission check (`-32012 Unauthorized`); periodic delivery-time re-verification; a `terminated` envelope ends the subscription when access is lost. *(Authorization)* | +| Unauthorized subscription, or continued delivery after access is revoked | Subscribe-time permission check (`-32012 Forbidden`); periodic delivery-time re-verification; a `terminated` envelope ends the subscription when access is lost. *(Authorization)* | | Treating event receipt as authority to act | Action-time authorization — the agent's response to an event goes through normal MCP authorization; receipt grants no privilege. *(Authorization)* | | SSRF / DNS rebinding (server coerced into requesting internal hosts) | Delivery-time IP validation against the IANA special-purpose registries, connecting to the validated IP with the original Host/SNI, and no redirects; optional callback-URL allowlist. *(Webhook Security → SSRF prevention)* | | Reflection / amplification DDoS (attacker subscribes a victim's URL) | Mandatory intent challenge (or allowlist) before activating delivery, cached per `(principal, url)`; subscribe and per-destination rate limits. *(Endpoint verification)* | @@ -783,7 +781,7 @@ The threats this extension is designed to defend against, and the mechanism addr | Eavesdropping in transit (payloads and `delivery.secret`) | TLS required — `https` callback URLs only; non-`https` subscribe is rejected. *(TLS requirement)* | | Cross-tenant subscription tampering (guessing the tuple to unsubscribe or rotate another tenant's secret) | Webhook requires an authenticated principal; subscription identity includes `principal`; the derived `id` is a non-capability routing handle. *(Subscription Identity)* | | Response oracle / internal-service probing via error detail | `deliveryStatus.lastError` is a fixed category string only — never raw endpoint bodies, headers, or status lines. *(Webhook Delivery Status)* | -| Server-side resource exhaustion (subscription flooding) | `-32013 TooManySubscriptions` limits; webhook subscriptions are TTL'd soft state that expire without refresh. *(Error Codes)* | +| Server-side resource exhaustion (subscription flooding) | `-32013 ResourceExhausted` limits; webhook subscriptions are TTL'd soft state that expire without refresh. *(Error Codes)* | ### Event Payloads Are Untrusted Data @@ -811,14 +809,14 @@ This reduces PII exposure in event infrastructure (logs, queues, buffers) and li |---|---| | Push | `notifications/events/terminated` on the SSE stream | | Poll | JSON-RPC error response to the next `events/poll` request | -| Webhook | Signed `{"type":"terminated",...}` envelope POSTed to the callback URL (see *Non-event webhook bodies*); the subscription no longer exists, so a subsequent refresh is a fresh subscribe and returns `-32012 Unauthorized` if the cause of termination still applies | +| Webhook | Signed `{"type":"terminated",...}` envelope POSTed to the callback URL (see *Non-event webhook bodies*); the subscription no longer exists, so a subsequent refresh is a fresh subscribe and returns `-32012 Forbidden` if the cause of termination still applies | ``` // Push -data: {"jsonrpc":"2.0","method":"notifications/events/terminated","params":{"error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}},"_meta":{"io.modelcontextprotocol/subscriptionId":1}}} +data: {"jsonrpc":"2.0","method":"notifications/events/terminated","params":{"error":{"code":-32012,"message":"Forbidden","data":{"reason":"Access revoked"}},"_meta":{"io.modelcontextprotocol/subscriptionId":1}}} // Webhook (POST body) -{"type":"terminated","error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}}} +{"type":"terminated","error":{"code":-32012,"message":"Forbidden","data":{"reason":"Access revoked"}}} ``` `notifications/events/terminated` has the shape `{error: {code: integer, message: string, data?: object}, _meta: {"io.modelcontextprotocol/subscriptionId": string|integer}}` — 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. @@ -919,6 +917,7 @@ These are the choices where reasonable alternatives existed; recorded so reviewe | `eventId` source | Server-assigned; SHOULD be the upstream's stable identifier | Dual-path delivery (e.g., webhook emit + poll backfill) of the same upstream event must collapse under client-side dedup. SDK auto-generates only when the author supplies none. | | Protocol-level flow control | None in v1 | The dominant bottleneck is LLM inference (seconds–minutes per event), not delivery. TCP back-pressure, `nextPollMs`, and client-side prioritization are sufficient for the target event rates. Credit-based control can be added later without breaking changes. | | New primitive vs extending `resources/subscribe` | New `events/*` primitive | Resources are content-addressed (URI → representation) without parameters or cursors; bolting filter params, replay, and three delivery modes onto them would distort that model more than a parallel primitive costs. Folding `resources/subscribe` into events later is left as an open question. | +| Error code surface | Five general-purpose codes (`NotFound`, `Forbidden`, `ResourceExhausted`, `Unsupported`, `CallbackEndpointError`) carrying specifics in typed `data`, plus base `InvalidParams` — not events-specific names | An earlier draft minted seven events-scoped codes (`EventNotFound`, `TooManySubscriptions`, `EndpointVerificationFailed`, `InvalidCallbackUrl`, `SubscriptionNotFound`, `DeliveryModeUnsupported`). Collapsing each family behind one general code with a typed `data` discriminator (the `-32003`/`-32004`/`-32042` pattern) keeps the shared `[-32000,-32099]` range uncluttered and lets future SEPs reuse rather than re-mint, while clients still distinguish every case they act on (by code, by method, or by `data`). Static request errors fold into `InvalidParams`. Defined here pending promotion to the base MCP error registry. | ## Appendix: End-to-End Example — GitHub MCP Server (Webhook Delivery) From 99f3589c836278dc083868e3eeaa7cc193214b23 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Thu, 11 Jun 2026 16:57:32 +0100 Subject: [PATCH 14/25] Make webhook TTL client-suggested with explicit no-expiry option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client now suggests a subscription lifetime via ttlMs on events/subscribe; the server's refreshBefore grant is authoritative and SHOULD only shorten (clamping up to a server minimum TTL is the one sanctioned exception). An explicit ttlMs: null requests no expiry, and refreshBefore: null grants it. This reframes server-side durability as the server's choice, coupled to the TTLs it grants: short grants keep the existing in-memory soft-state model, while long or no-expiry grants shift durability and cleanup onto the server (persist across restarts, persist verification status, failure-based GC instead of TTL expiry, no expectation of unsubscribe). Rationale: long-lived tenants with many subscriptions shouldn't be forced into a server-invented refresh cadence, and a one-year TTL is practically unlimited anyway — better to offer no expiry explicitly and attach the real obligations to it. --- docs/design-sketch-proposal.md | 64 ++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index eac7d5d..2f1dca9 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -8,11 +8,11 @@ Events lets 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. A server declares event types via `events/list` (each with a name, an `inputSchema` for subscription params, a `payloadSchema`, and the delivery modes it supports). The client subscribes with `(name, params)` and receives event occurrences `{eventId, name, timestamp, data, cursor}`. -**Delivery modes.** Three, advertised per event type; none is mandatory. **Poll** (`events/poll`) is request/response: client sends `{name, params, cursor}`, gets back `{events[], cursor, nextPollMs}`. **Push** (`events/stream`) is a long-lived request: events arrive as `notifications/events/event`, with a heartbeat carrying the current cursor during quiet periods. **Webhook** (`events/subscribe`) registers an `https` callback URL plus a client-supplied `whsec_` secret; the server POSTs each event there, signed per Standard Webhooks (`webhook-id/-timestamp/-signature` + `X-MCP-Subscription-Id`); subscriptions have a TTL and the client refreshes before `refreshBefore`. The client SDK negotiates the mode and runs the loop; the model only sees arriving events. +**Delivery modes.** Three, advertised per event type; none is mandatory. **Poll** (`events/poll`) is request/response: client sends `{name, params, cursor}`, gets back `{events[], cursor, nextPollMs}`. **Push** (`events/stream`) is a long-lived request: events arrive as `notifications/events/event`, with a heartbeat carrying the current cursor during quiet periods. **Webhook** (`events/subscribe`) registers an `https` callback URL plus a client-supplied `whsec_` secret; the server POSTs each event there, signed per Standard Webhooks (`webhook-id/-timestamp/-signature` + `X-MCP-Subscription-Id`); subscriptions carry a TTL the client suggests (`ttlMs`) and the server grants (`refreshBefore`, SHOULD be ≤ the suggestion; `null` = no expiry), and the client refreshes before `refreshBefore`. The client SDK negotiates the mode and runs the loop; the model only sees arriving events. **Cursors and replay.** A cursor is an opaque server-defined position. The client persists the cursor it receives and passes it back on the next poll/reconnect/refresh; the server resumes from there. Servers whose upstream has no addressable history return `cursor: null` (no replay). `maxAgeMs` bounds replay so a stale cursor doesn't dump an unbounded backlog. `eventId` (preferably the upstream's stable id) is for client-side dedup. Reliable, ordered delivery is achievable when the upstream is durable, but not mandated — `truncated: true` in any response signals events were skipped (stale cursor, `maxAgeMs` floor, or server-side ceiling). -**State and identity.** The client owns the canonical subscription list; the server holds nothing durable. Poll is per-request; push is connection-scoped; webhook requires an authenticated principal and is TTL'd soft state keyed on `(principal, delivery.url, name, params)` — the response returns a server-derived `id` for routing only. `events/unsubscribe` is eager cleanup; otherwise subscriptions lapse on their own. Authorization is the same MCP principal as for tools; event payloads are untrusted data with the same injection considerations as tool results. +**State and identity.** The client owns the canonical subscription list. Poll is per-request; push is connection-scoped; webhook requires an authenticated principal and is TTL-scoped state keyed on `(principal, delivery.url, name, params)` — how durably the server holds it is the server's choice, coupled to the TTLs it grants (short TTLs → in-memory soft state recovered by refresh; long or no expiry → the server takes on durability; see *Subscription TTL*). The response returns a server-derived `id` for routing only. `events/unsubscribe` is eager cleanup; otherwise subscriptions lapse at TTL expiry. Authorization is the same MCP principal as for tools; event payloads are untrusted data with the same injection considerations as tool results. ## Capability Declaration @@ -101,7 +101,7 @@ There are three delivery modes with different subscription mechanisms: - **Poll mode:** Client calls `events/poll` with 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/stream` request 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/subscribe` to 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-calling `events/subscribe` before 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. +- **Webhook mode:** Client calls `events/subscribe` to register a callback URL. The server POSTs events to that URL as they occur. Subscriptions carry a TTL negotiated at subscribe time — the client suggests a lifetime, the server grants one (possibly no expiry; see *Subscription TTL*). Unless granted no expiry, the client must periodically refresh by re-calling `events/subscribe` before the TTL expires; if it stops refreshing, the subscription expires and the server reclaims resources. Designed for remote servers where maintaining a long-lived connection is impractical. ### Error Codes @@ -280,7 +280,7 @@ Each event notification on the push stream includes a `cursor` field. This curso ### 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. +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 are TTL-scoped: the client suggests a lifetime, the server grants one, and the subscription expires if the client stops refreshing before the granted expiry. The TTL is the server's resource-control knob — short grants keep subscriptions as in-memory soft state cleaned up by expiry; long or no-expiry grants shift the durability and cleanup burden onto the server (see *Subscription TTL*). 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: @@ -295,7 +295,7 @@ sequenceDiagram participant Server as MCP Server participant Hook as Webhook Endpoint - SDK->>Server: events/subscribe {name, params, delivery: {url, secret}, cursor} + SDK->>Server: events/subscribe {name, params, delivery: {url, secret}, cursor, ttlMs} Server-->>SDK: {id, refreshBefore, cursor, truncated} loop as events occur Server->>Hook: POST {eventId, name, timestamp, data, cursor} + Standard Webhooks signature @@ -330,7 +330,8 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau "secret": "whsec_" }, "cursor": null, - "maxAgeMs": 300000 + "maxAgeMs": 300000, + "ttlMs": 3600000 // suggested TTL; omit = server default, null = request no expiry (see Subscription TTL) } } ``` @@ -339,7 +340,7 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau // Response { "id": "sub_a3f1c8e2b0d49f7e", // server-derived; stable for this (principal, url, name, params) - "refreshBefore": "2026-02-19T16:30:00Z", + "refreshBefore": "2026-02-19T16:30:00Z", // authoritative grant; SHOULD be ≤ the suggested ttlMs; null = no expiry (see Subscription TTL) "cursor": "cursor_start_001", // safe-to-persist watermark (same semantics as payload cursor); advances the client's cursor even if no events arrive before next refresh (see Cursor Lifecycle) "truncated": false // true if delivery started later than the supplied cursor (retention window, maxAgeMs floor, or server-side ceiling) } @@ -351,10 +352,26 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau - `delivery.secret` is REQUIRED. The client supplies the HMAC signing secret; the server never generates one. The value MUST be a Standard Webhooks symmetric secret: the literal prefix `whsec_` followed by base64 of 24–64 random bytes. Servers MUST reject values that do not satisfy this with `InvalidParams`. Client SDKs SHOULD generate the secret on the application's behalf rather than expose an interface that encourages hand-picked values. - `id` (response only) is a server-derived handle for the subscription, deterministic over `(principal, delivery.url, name, params)`. The server returns it so the receiver can route deliveries (it appears in the `X-MCP-Subscription-Id` header on every POST). It is stable across refreshes and server restarts. The client does not generate or send it. - `cursor` (request) tells the server where to begin delivery. `null` means "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 per-subscription cursor position across restarts; it computes a safe-to-persist watermark for inclusion in each delivered payload (see *Webhook Event Delivery*) and returns that same watermark in each subscribe/refresh response so the client's cursor advances during quiet periods. Both sources carry the same value semantics, so the client persists whichever it receives most recently and 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. -- `refreshBefore` is mandatory. It is an ISO 8601 timestamp indicating when the subscription will expire. The client MUST re-call `events/subscribe` with the same subscription key before this time to keep the subscription alive. The server resets the TTL on each refresh. +- `ttlMs` (request, optional) is the client's suggested subscription lifetime; `refreshBefore` (response, always present, nullable) is the server's authoritative grant — an ISO 8601 timestamp indicating when the subscription will expire, or `null` for no expiry. See *Subscription TTL* below for the negotiation rules. Unless granted no expiry, the client MUST re-call `events/subscribe` with the same subscription key before `refreshBefore` to keep the subscription alive; the server re-grants a TTL on each refresh. - `cursor` (response) is the same **safe-to-persist watermark** the server includes in delivery payloads — i.e., a position at or before which everything has been acknowledged or abandoned. Returning it here lets the client's cursor advance during quiet periods when no event POSTs arrive; without this, a long quiet stretch could leave the client holding a cursor older than the upstream's retention window. When deliveries are in flight, the response cursor is at or behind the upstream head (it does not advance past unacked events), so persisting it never skips anything. It is `null` for event types that do not support replay. - `events/subscribe` is 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 (event name, params, callback URL, secret, derived id) 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*). +- The server holds subscription state (event name, params, callback URL, secret, derived id) with a per-subscription TTL. How durably it holds that state is its own choice, coupled to the TTLs it grants. A server granting short TTLs can keep everything in memory — if it restarts, clients 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, while emit-only event types lose them (see *Emit-only event types*). A server granting long or no-expiry TTLs MUST retain subscriptions for the lifetime it granted, including across restarts (see *Subscription TTL*). + +#### Subscription TTL + +Every webhook subscription has a lifetime negotiated at subscribe time: the client suggests, the server decides, and the server's answer is authoritative. + +- `ttlMs` (request, optional, nullable integer) is the client's suggested lifetime in milliseconds. The client knows best how long it is likely to be around — a short-lived session should suggest minutes; a long-lived tenant holding many subscriptions can suggest days or more, so it isn't forced to spend time refreshing on a cadence the server invented. Omitting the field means "server default." An explicit `ttlMs: null` requests a subscription with **no expiry**. +- `refreshBefore` (response) is the grant. It SHOULD be less than or equal to the suggestion — the client asked for what it can commit to refreshing, and granting longer risks the server delivering to an endpoint past the client's expected lifetime. The one sanctioned exception is a server-side floor: a server MAY clamp an impractically short suggestion up to its minimum TTL to protect itself from refresh storms. Clamping in either direction is self-announcing — the client reads the granted `refreshBefore` and schedules its refresh loop from that, so a clamped grant is not an error and there is no rejection path for TTL values. +- `refreshBefore: null` grants **no expiry**: the subscription persists until `events/unsubscribe` or server-initiated termination. A server MUST NOT return `null` unless the client suggested `ttlMs: null` — no expiry exceeds every finite suggestion. A server unwilling to grant it simply returns a finite `refreshBefore`, which the client treats like any other grant. + +**TTL is the server's resource-control knob.** A server that wants refresh-driven cleanup and in-memory soft state grants short TTLs. A server that grants long TTLs is choosing to hold subscription state for that long, and should hold it durably enough to honour the grant. There is no sharp boundary to defend here: a one-year TTL is, in every practical sense, unlimited, and excluding `null` from the protocol would only invite "999 years." Offering no expiry explicitly lets the spec attach the real obligations to it. A server that grants no expiry accepts that: + +- the client is not expected to refresh or unsubscribe, ever — keeping the subscription alive is now the server's job, not the client's; +- restart recovery via the refresh cycle no longer applies — the server MUST persist no-expiry subscriptions across restarts, because a client that never refreshes will never detect (or repair) a silently dropped one; +- TTL expiry no longer garbage-collects orphans or dead endpoints — the server MAY drop a no-expiry subscription after sustained delivery failure (server-defined window), and SHOULD attempt a `terminated` envelope when it does. + +Even with no expiry, clients SHOULD still re-call `events/subscribe` occasionally: the refresh response is where the cursor advances during quiet periods, where `deliveryStatus` surfaces delivery problems, and where suspended delivery reactivates (see *Webhook Event Delivery*). The difference is that correctness no longer depends on it. #### Subscription Identity @@ -374,7 +391,7 @@ On an idempotent subscribe against an existing key, the server updates the remai |---|---| | `delivery.secret` | Replaced. To rotate, the client supplies a new value on refresh; the server SHOULD dual-sign (Standard Webhooks multi-signature) with old and new for a short grace window so in-flight deliveries verify under either. | | `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. | +| TTL | Re-granted. The refresh carries a (possibly different) `ttlMs` suggestion and the server returns a fresh `refreshBefore` under the same rules as the initial subscribe — so a client can lengthen or shorten its refresh cadence, or convert a no-expiry subscription back to a finite one by suggesting a finite `ttlMs`. | | `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 key includes `principal` and `delivery.url`, two distinct tenants subscribing to the same `(name, params)` get distinct subscriptions. A caller who learns another tenant's derived `id` gains nothing — `id` is not accepted as input to any method. @@ -412,7 +429,7 @@ X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e - **Delivery model.** The server retries each event independently with exponential backoff on non-`2xx` responses. 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 uses `eventId` for deduplication and `timestamp` for ordering if needed. Because the payload `cursor` is 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 `2xx` response from the webhook endpoint signals that the event has been accepted and the server need not retry it. The endpoint SHOULD NOT return `2xx` until 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.** Because the secret is client-supplied, the receiver can verify the very first delivery — there is no window where a delivery arrives before the receiver knows the secret. A receiver that gets a delivery for an `id` it has not yet been told to route (e.g., the subscribe response has not propagated to the gateway) SHOULD return a retryable status (`503` or `425 Too Early`); the server's retry/backoff redelivers once the receiver is ready. `eventId` deduplication 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 (sets `active: true`) and the server resumes retrying pending events; if the client never refreshes, the subscription expires naturally at TTL. +- After repeated failures (server-defined threshold), the server MAY suspend delivery (`deliveryStatus.active: false`). A subsequent successful refresh reactivates it (sets `active: true`) and the server resumes retrying pending events; if the client never refreshes, the subscription expires naturally at TTL. A no-expiry subscription has no such backstop: the server MAY drop it after sustained delivery failure (server-defined window) and SHOULD attempt a `terminated` envelope when it does (see *Subscription TTL*). #### Non-event webhook bodies @@ -467,7 +484,7 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre **Endpoint verification (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **either**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**. A reachable endpoint that fails to echo yields `-32015 CallbackEndpointError` with `data.reason: "challenge_failed"`; an unreachable one yields the same code with the relevant connection-failure category (`connection_refused`, `timeout`, or `tls_error`). -Verification is cached per `(principal, url)`: a successful handshake or allowlist hit covers that principal's subscriptions to that URL across refreshes and `params`, so varying `params` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. +Verification is cached per `(principal, url)`: a successful handshake or allowlist hit covers that principal's subscriptions to that URL across refreshes and `params`, so varying `params` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. A server that persists no-expiry subscriptions across restarts (see *Subscription TTL*) MUST persist their verification status alongside them — a persisted subscription resumes delivery without a re-subscribe, so there is no later handshake to lean on, and the stored record only exists because verification succeeded. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. **Server identity (optional).** A server MAY additionally sign deliveries and verification POSTs with an asymmetric key — Standard Webhooks `v1a,` (ed25519), appended alongside the `v1,` HMAC — so a gateway can verify *which* server sent a delivery, enforce a known-server allowlist, and retain protection if an HMAC secret leaks. The verifying public key MUST be discovered from an origin the client already authenticates, and never from the challenge or delivery body, which the attacker controls (that would be trust-on-first-use). The discovery mechanism is a SEP-review-time decision contingent on [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) (server cards), still draft: if server cards are published before this extension, the key is carried inline on the server's card (provisionally a `webhooks.signingKeys` member holding an ed25519 JWK set); otherwise it is published as a standalone JWKS document at a well-known path on the server's origin, `/.well-known/mcp-webhook-jwks.json`. Exactly one of these will be normative in the final SEP — they are not two options offered to implementers. Either way the key is fetched from the same origin the gateway connects to for MCP, and is distinct from the OAuth `jwks_uri` ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), which carries the authorization server's token-signing keys, not the MCP server's webhook-signing key. Keys carry a `kid`; rotation publishes the new key alongside the old. Endpoints that do not enforce server identity ignore `v1a,`. @@ -497,7 +514,7 @@ HMAC is verified by application code after the request has been routed. It does #### 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. +`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. No-expiry subscriptions never lapse, so `events/unsubscribe` is the only client-initiated cleanup for them — but a server that grants no expiry MUST NOT count on receiving it (see *Subscription TTL*). ```jsonc { @@ -520,7 +537,7 @@ HMAC is verified by application code after the request has been routed. It does 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. +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. No-expiry subscriptions are the exception: their cleanup is the granting server's responsibility (failure-based GC per *Webhook Event Delivery*), which is part of what a server accepts by granting them. ## Cursor Lifecycle @@ -592,13 +609,13 @@ The SDK calls this function in different contexts depending on the delivery mode 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. +**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 with per-subscription TTLs granted within the server's configured cap. When granted TTLs are short, in-memory state is sufficient — no external storage is required: if the server restarts, all webhook subscriptions are lost and clients 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 the deliberate trade: short TTL + refresh substitutes for durable subscription storage. A server configured to grant long or no-expiry TTLs MUST instead persist subscriptions for the lifetime it grants (see *Subscription TTL*); SDKs SHOULD refuse a no-expiry cap unless the author has wired up durable storage. ```python -# Webhook mode uses in-memory subscription state with TTL. -# No external storage needed — clients refresh before expiry. +# Short granted TTLs let the SDK keep subscription state in memory — +# clients refresh before expiry, so nothing needs to survive a restart. server = MCPServer( - webhook_ttl=timedelta(minutes=30), # subscriptions expire after 30 min + webhook_ttl=timedelta(minutes=30), # cap on granted TTL; client suggestions are clamped to ≤ this ) ``` @@ -754,7 +771,7 @@ sub = await client.subscribe("incident.created", params={...}, delivery="webhook **Push mode:** The SDK opens one `events/stream` per subscription and listens for notifications, routing them by `_meta["io.modelcontextprotocol/subscriptionId"]`. 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 generates a `whsec_` secret, registers it (or its derivation input) with the gateway, then calls `events/subscribe` with `delivery: {url, secret}`. The response provides the server-derived `id` for routing. It runs a background refresh loop, re-calling `events/subscribe` before `refreshBefore` expires. The SDK monitors `deliveryStatus` on each refresh to detect delivery failures. The webhook receiver (which may be a forward proxy) accepts incoming POSTs, looks up the secret by `X-MCP-Subscription-Id`, verifies the Standard Webhooks signature, checks timestamp freshness, and routes events to the appropriate subscription iterator. On `sub.cancel()`, the SDK calls `events/unsubscribe` for eager cleanup. +**Webhook mode:** The SDK generates a `whsec_` secret, registers it (or its derivation input) with the gateway, then calls `events/subscribe` with `delivery: {url, secret}`. The response provides the server-derived `id` for routing. It runs a background refresh loop, re-calling `events/subscribe` before `refreshBefore` expires (for a no-expiry grant, the loop drops to an occasional health-check cadence — see *Subscription TTL*). The SDK monitors `deliveryStatus` on each refresh to detect delivery failures. The webhook receiver (which may be a forward proxy) accepts incoming POSTs, looks up the secret by `X-MCP-Subscription-Id`, verifies the Standard Webhooks signature, 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 @@ -781,7 +798,7 @@ The threats this extension is designed to defend against, and the mechanism addr | Eavesdropping in transit (payloads and `delivery.secret`) | TLS required — `https` callback URLs only; non-`https` subscribe is rejected. *(TLS requirement)* | | Cross-tenant subscription tampering (guessing the tuple to unsubscribe or rotate another tenant's secret) | Webhook requires an authenticated principal; subscription identity includes `principal`; the derived `id` is a non-capability routing handle. *(Subscription Identity)* | | Response oracle / internal-service probing via error detail | `deliveryStatus.lastError` is a fixed category string only — never raw endpoint bodies, headers, or status lines. *(Webhook Delivery Status)* | -| Server-side resource exhaustion (subscription flooding) | `-32013 ResourceExhausted` limits; webhook subscriptions are TTL'd soft state that expire without refresh. *(Error Codes)* | +| Server-side resource exhaustion (subscription flooding) | `-32013 ResourceExhausted` limits; webhook subscriptions expire without refresh at the server-granted TTL — a server that grants long or no-expiry TTLs explicitly accepts the corresponding retention burden. *(Error Codes; Subscription TTL)* | ### Event Payloads Are Untrusted Data @@ -829,7 +846,7 @@ The spec does not mandate specific governance mechanisms but is designed to enab - **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, call `events/unsubscribe` for immediate termination, or simply stop refreshing and the subscription will expire at TTL. +- **Kill switch:** For poll mode, stop polling. For push mode, abort the request stream (HTTP) or send `notifications/cancelled` (stdio). For webhook mode, call `events/unsubscribe` for immediate termination, or simply stop refreshing and the subscription will expire at TTL (no-expiry subscriptions must be unsubscribed explicitly — see *Subscription TTL*). - **Rate limiting:** `nextPollMs` allows servers to control polling frequency dynamically. Clients SHOULD respect it. Enterprise deployments may impose additional rate limits. ## Relationship to Existing Primitives @@ -911,7 +928,8 @@ These are the choices where reasonable alternatives existed; recorded so reviewe | Multimedia / high-throughput streams | Out of scope | Streaming media has fundamentally different transport, framing, and back-pressure requirements. Designing for human/automation-scale event rates keeps the SDK surface tractable; a media primitive would be a separate proposal. | | Reliable / ordered delivery | Supported (via opaque cursors + `eventId` dedup), not mandated | Genuine use cases need it, so the cursor abstraction makes at-least-once-with-replay achievable when the upstream is durable. But many upstreams are push-only with no history; mandating reliability would force them to fake it. Servers may return `cursor: null` to opt out. | | Webhook subscription identity | `(principal, delivery.url, name, params)`; webhook requires auth; server-derived `id` for routing only | The tuple is everything that semantically defines a subscription, so subscribe is naturally idempotent and there is nothing for the client to generate or persist. The derived `id` is a non-capability routing handle. Webhook requires an authenticated principal so the tuple is not guessable by other tenants; without it, anyone could unsubscribe or rotate another tenant's secret. Server-generated and client-generated `id` schemes were considered; both add state or entropy obligations the tuple makes unnecessary. | -| Server-side subscription durability | None — push is connection-scoped, webhook is TTL'd soft state, poll is per-request | Clients hold the canonical subscription list and reconstruct server state by re-subscribing. A server crash never orphans a client; a vanished client never leaks server resources. Trade-off: no server-side "list all subscriptions" without per-principal enumeration. | +| Server-side subscription durability | Server-controlled via granted TTL — push is connection-scoped, poll is per-request, webhook durability is coupled to the TTLs the server grants | Clients hold the canonical subscription list and reconstruct server state by re-subscribing. A server that wants soft in-memory state grants short TTLs: a crash never orphans a client, a vanished client never leaks resources. A server granting long or no-expiry TTLs opts into persisting subscriptions for the granted lifetime. Trade-off: no server-side "list all subscriptions" without per-principal enumeration. | +| Webhook TTL negotiation | Client suggests `ttlMs`; server grant (`refreshBefore`) is authoritative and SHOULD only shorten, except clamping up to a server minimum; `ttlMs: null` may be granted as no expiry | The client knows its own lifetime best — a long-lived tenant holding many subscriptions shouldn't be forced into a server-invented refresh cadence (SIP `Expires` / WebSub `lease_seconds` precedent). The ≤ rule keeps the server from delivering past the client's expected lifetime; the floor exception protects servers from refresh storms. No expiry is offered explicitly rather than excluded: a one-year TTL is practically unlimited anyway, and being explicit lets the spec attach the real obligations (durable state, failure-based GC, no expectation of unsubscribe) to the server that grants it. | | Webhook callback TLS | `https` is MUST | Both event payloads and the `delivery.secret` (in the subscribe request) would otherwise transit in cleartext. No legitimate remote-server deployment lacks TLS in 2026. | | Replay bound | Time-floor `maxAgeMs` only; no count bound | Time-anchored start is the universal primitive across replayable systems (Kinesis `AT_TIMESTAMP`, JetStream `by_start_time`, Kafka `offsetsForTimes`, RabbitMQ/Pulsar/PubSub seek-to-time). Count-bounded subscribe ("newest N") requires backward-seek that many upstreams can't do cheaply and has ambiguous which-end semantics. | | Cursor advancement during quiet periods | Heartbeat carries cursor (push); refresh response carries cursor (webhook); poll already returns cursor on empty results | Without this, a long quiet stretch leaves the client holding a cursor older than the upstream's retention window, yielding a spurious `truncated: true` even though nothing happened. Reuses existing periodic messages; no new traffic. | @@ -962,7 +980,7 @@ sequenceDiagram Note over Redis: no refresh → key expires,
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). +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* when granted TTLs are short, as in this example: 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). A server granting no-expiry subscriptions would need this state to be durable (see *Subscription TTL*). Note there are *two* webhook hops here, and they are unrelated: GitHub → MCP Server (step 5) is GitHub's native webhook product, configured server-side and outside this spec; MCP Server → Forward Proxy (step 8) is the MCP `events/subscribe` webhook defined above. From 2f47edf7e28e77bf12c015f557e3d51cc066c9e0 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Fri, 12 Jun 2026 19:41:26 +0100 Subject: [PATCH 15/25] Allow prior out-of-band verification as a third endpoint verification path --- docs/design-sketch-proposal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index 2f1dca9..925b85f 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -482,7 +482,7 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre **SSRF prevention.** The server MUST validate callback URLs. Servers SHOULD reject URLs whose resolved IP is not globally routable per the [IANA IPv4](https://www.iana.org/assignments/iana-ipv4-special-registry/) and [IPv6](https://www.iana.org/assignments/iana-ipv6-special-registry/) 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 (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **either**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**. A reachable endpoint that fails to echo yields `-32015 CallbackEndpointError` with `data.reason: "challenge_failed"`; an unreachable one yields the same code with the relevant connection-failure category (`connection_refused`, `timeout`, or `tls_error`). +**Endpoint verification (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **one of**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**; or (c) prior **out-of-band verification** of the URL by the server through its own mechanism (e.g., registration and confirmation via the server's dashboard). A reachable endpoint that fails to echo yields `-32015 CallbackEndpointError` with `data.reason: "challenge_failed"`; an unreachable one yields the same code with the relevant connection-failure category (`connection_refused`, `timeout`, or `tls_error`). Verification is cached per `(principal, url)`: a successful handshake or allowlist hit covers that principal's subscriptions to that URL across refreshes and `params`, so varying `params` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. A server that persists no-expiry subscriptions across restarts (see *Subscription TTL*) MUST persist their verification status alongside them — a persisted subscription resumes delivery without a re-subscribe, so there is no later handshake to lean on, and the stored record only exists because verification succeeded. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. From 082166f0bae5f93d19f8803f3c0e5cbe92322c69 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Mon, 15 Jun 2026 12:37:28 +0100 Subject: [PATCH 16/25] Rename inner subscription params field to arguments The subscribe/poll/stream requests nested a params object inside JSON-RPC params, which read as params.params in code and docs. The inner field is now arguments, matching the inputSchema/arguments pairing on tools/call. Applies to the wire examples, the subscription identity tuple, and the SDK guidance snippets; the outer JSON-RPC params envelope is unchanged. --- docs/design-sketch-proposal.md | 108 ++++++++++++++++----------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index 925b85f..a4b50af 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -6,13 +6,13 @@ ## Summary -Events lets 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. A server declares event types via `events/list` (each with a name, an `inputSchema` for subscription params, a `payloadSchema`, and the delivery modes it supports). The client subscribes with `(name, params)` and receives event occurrences `{eventId, name, timestamp, data, cursor}`. +Events lets 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. A server declares event types via `events/list` (each with a name, an `inputSchema` for subscription arguments, a `payloadSchema`, and the delivery modes it supports). The client subscribes with `(name, arguments)` and receives event occurrences `{eventId, name, timestamp, data, cursor}`. -**Delivery modes.** Three, advertised per event type; none is mandatory. **Poll** (`events/poll`) is request/response: client sends `{name, params, cursor}`, gets back `{events[], cursor, nextPollMs}`. **Push** (`events/stream`) is a long-lived request: events arrive as `notifications/events/event`, with a heartbeat carrying the current cursor during quiet periods. **Webhook** (`events/subscribe`) registers an `https` callback URL plus a client-supplied `whsec_` secret; the server POSTs each event there, signed per Standard Webhooks (`webhook-id/-timestamp/-signature` + `X-MCP-Subscription-Id`); subscriptions carry a TTL the client suggests (`ttlMs`) and the server grants (`refreshBefore`, SHOULD be ≤ the suggestion; `null` = no expiry), and the client refreshes before `refreshBefore`. The client SDK negotiates the mode and runs the loop; the model only sees arriving events. +**Delivery modes.** Three, advertised per event type; none is mandatory. **Poll** (`events/poll`) is request/response: client sends `{name, arguments, cursor}`, gets back `{events[], cursor, nextPollMs}`. **Push** (`events/stream`) is a long-lived request: events arrive as `notifications/events/event`, with a heartbeat carrying the current cursor during quiet periods. **Webhook** (`events/subscribe`) registers an `https` callback URL plus a client-supplied `whsec_` secret; the server POSTs each event there, signed per Standard Webhooks (`webhook-id/-timestamp/-signature` + `X-MCP-Subscription-Id`); subscriptions carry a TTL the client suggests (`ttlMs`) and the server grants (`refreshBefore`, SHOULD be ≤ the suggestion; `null` = no expiry), and the client refreshes before `refreshBefore`. The client SDK negotiates the mode and runs the loop; the model only sees arriving events. **Cursors and replay.** A cursor is an opaque server-defined position. The client persists the cursor it receives and passes it back on the next poll/reconnect/refresh; the server resumes from there. Servers whose upstream has no addressable history return `cursor: null` (no replay). `maxAgeMs` bounds replay so a stale cursor doesn't dump an unbounded backlog. `eventId` (preferably the upstream's stable id) is for client-side dedup. Reliable, ordered delivery is achievable when the upstream is durable, but not mandated — `truncated: true` in any response signals events were skipped (stale cursor, `maxAgeMs` floor, or server-side ceiling). -**State and identity.** The client owns the canonical subscription list. Poll is per-request; push is connection-scoped; webhook requires an authenticated principal and is TTL-scoped state keyed on `(principal, delivery.url, name, params)` — how durably the server holds it is the server's choice, coupled to the TTLs it grants (short TTLs → in-memory soft state recovered by refresh; long or no expiry → the server takes on durability; see *Subscription TTL*). The response returns a server-derived `id` for routing only. `events/unsubscribe` is eager cleanup; otherwise subscriptions lapse at TTL expiry. Authorization is the same MCP principal as for tools; event payloads are untrusted data with the same injection considerations as tool results. +**State and identity.** The client owns the canonical subscription list. Poll is per-request; push is connection-scoped; webhook requires an authenticated principal and is TTL-scoped state keyed on `(principal, delivery.url, name, arguments)` — how durably the server holds it is the server's choice, coupled to the TTLs it grants (short TTLs → in-memory soft state recovered by refresh; long or no expiry → the server takes on durability; see *Subscription TTL*). The response returns a server-derived `id` for routing only. `events/unsubscribe` is eager cleanup; otherwise subscriptions lapse at TTL expiry. Authorization is the same MCP principal as for tools; event payloads are untrusted data with the same injection considerations as tool results. ## Capability Declaration @@ -88,7 +88,7 @@ Servers advertise event support in their capabilities: **Notes:** - `delivery` lists the delivery modes this event type supports — any non-empty subset of `"poll"`, `"push"`, `"webhook"`. No mode is mandatory. A client that cannot use any of the listed modes cannot subscribe to this event type. -- `inputSchema` is 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 the `inputSchema` on tools for consistency. +- `inputSchema` is a JSON Schema describing valid subscription arguments — these may include filters (which narrow the event stream), transforms (which modify payloads), or other server-defined configuration. This mirrors the `inputSchema`/`arguments` pairing on tools for consistency. - `payloadSchema` describes the shape of `data` in delivered events. ### Dynamic Event Types: `notifications/events/list_changed` @@ -99,7 +99,7 @@ If the set of available event types changes at runtime (e.g., a plugin is loaded There are three delivery modes with different subscription mechanisms: -- **Poll mode:** Client calls `events/poll` with 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*). +- **Poll mode:** Client calls `events/poll` with event name, arguments, 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/stream` request 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/subscribe` to register a callback URL. The server POSTs events to that URL as they occur. Subscriptions carry a TTL negotiated at subscribe time — the client suggests a lifetime, the server grants one (possibly no expiry; see *Subscription TTL*). Unless granted no expiry, the client must periodically refresh by re-calling `events/subscribe` before the TTL expires; if it stops refreshing, the subscription expires and the server reclaims resources. Designed for remote servers where maintaining a long-lived connection is impractical. @@ -107,9 +107,9 @@ There are three delivery modes with different subscription mechanisms: | Code | Message | Meaning | |------|---------|---------| -| `-32602` | `InvalidParams` | Request is statically invalid — params don't match the event's inputSchema, the callback `delivery.url` is malformed or non-`https`, or `delivery.secret` is not a valid `whsec_` value (standard JSON-RPC invalid params). | +| `-32602` | `InvalidParams` | Request is statically invalid — arguments don't match the event's inputSchema, the callback `delivery.url` is malformed or non-`https`, or `delivery.secret` is not a valid `whsec_` value (standard JSON-RPC invalid params). | | `-32011` | `NotFound` | A referenced entity does not exist — an unknown event name, or no subscription matching the key on `events/unsubscribe`. The method implies which; `data.kind` (`"event"` \| `"subscription"`) MAY disambiguate. | -| `-32012` | `Forbidden` | The authenticated principal is not permitted for this event/params combination, or its access was revoked. | +| `-32012` | `Forbidden` | The authenticated principal is not permitted for this event/arguments combination, or its access was revoked. | | `-32013` | `ResourceExhausted` | A server-imposed limit or quota was reached. `data.limit` names it (e.g. `"subscriptions"`); `data.max` MAY give the ceiling. | | `-32014` | `Unsupported` | The request is well-formed but a requested capability or option is not supported here — e.g. a delivery mode the event type does not offer. `data` identifies it (e.g. `{ "feature": "deliveryMode", "value": "push" }`). | | `-32015` | `CallbackEndpointError` | A client-supplied callback endpoint failed verification or could not be reached (webhook mode only). `data.reason` is one of the `lastError` categories (`connection_refused`, `timeout`, `tls_error`, `http_4xx`, `http_5xx`, `challenge_failed`). | @@ -127,7 +127,7 @@ sequenceDiagram participant Server as MCP Server loop every nextPollMs - SDK->>Server: events/poll {name, params, cursor} + SDK->>Server: events/poll {name, arguments, cursor} Server-->>SDK: {events[], cursor, truncated, hasMore, nextPollMs} end Note over SDK: LLM invoked only when
events[] is non-empty @@ -140,7 +140,7 @@ Each `events/poll` request carries one subscription. A client with multiple subs ```jsonc { "name": "email.received", - "params": { + "arguments": { "from": "*@anthropic.com", "redact_pii": true }, @@ -195,7 +195,7 @@ Each entry in `events[]`, the params of `notifications/events/event`, and the bo - `hasMore` indicates whether additional events are available beyond the returned batch. When `true`, the client should poll again immediately with the updated cursor. When `false`, the client waits `nextPollMs` before the next poll. - `nextPollMs` allows the server to dynamically adjust polling frequency (e.g., back off when rate-limited upstream, speed up when activity is detected). Ignored when `hasMore` is `true`. Clients SHOULD apply a configurable floor (default 1000 ms) to guard against a misbehaving server inducing a tight loop. - Empty `events` array 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.) +- The server holds no protocol-required per-client subscription state. Each poll request is self-contained: the client provides the event name, arguments, 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 (`NotFound`, `Forbidden`, `InvalidParams`, `Unsupported`) 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 @@ -213,7 +213,7 @@ sequenceDiagram participant SDK as Client SDK participant Server as MCP Server - SDK->>Server: events/stream {name, params, cursor} + SDK->>Server: events/stream {name, arguments, cursor} activate Server Server-->>SDK: notifications/events/active {cursor, truncated, _meta.subscriptionId} loop as events occur @@ -235,7 +235,7 @@ sequenceDiagram "id": 1, "params": { "name": "email.received", - "params": { "from": "*@anthropic.com", "redact_pii": true }, + "arguments": { "from": "*@anthropic.com", "redact_pii": true }, "cursor": null, "maxAgeMs": 300000 } @@ -295,7 +295,7 @@ sequenceDiagram participant Server as MCP Server participant Hook as Webhook Endpoint - SDK->>Server: events/subscribe {name, params, delivery: {url, secret}, cursor, ttlMs} + SDK->>Server: events/subscribe {name, arguments, delivery: {url, secret}, cursor, ttlMs} Server-->>SDK: {id, refreshBefore, cursor, truncated} loop as events occur Server->>Hook: POST {eventId, name, timestamp, data, cursor} + Standard Webhooks signature @@ -307,7 +307,7 @@ sequenceDiagram Server-->>SDK: {refreshBefore', cursor, deliveryStatus} end opt explicit teardown (else: stop refreshing → TTL expiry) - SDK->>Server: events/unsubscribe {name, params, delivery: {url}} + SDK->>Server: events/unsubscribe {name, arguments, delivery: {url}} Server-->>SDK: (ack) end ``` @@ -323,7 +323,7 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau "id": 2, "params": { "name": "incident.created", - "params": { "severity": "P1" }, + "arguments": { "severity": "P1" }, "delivery": { "mode": "webhook", "url": "https://proxy.example.com/hooks/client123", @@ -339,7 +339,7 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau ```jsonc // Response { - "id": "sub_a3f1c8e2b0d49f7e", // server-derived; stable for this (principal, url, name, params) + "id": "sub_a3f1c8e2b0d49f7e", // server-derived; stable for this (principal, url, name, arguments) "refreshBefore": "2026-02-19T16:30:00Z", // authoritative grant; SHOULD be ≤ the suggested ttlMs; null = no expiry (see Subscription TTL) "cursor": "cursor_start_001", // safe-to-persist watermark (same semantics as payload cursor); advances the client's cursor even if no events arrive before next refresh (see Cursor Lifecycle) "truncated": false // true if delivery started later than the supplied cursor (retention window, maxAgeMs floor, or server-side ceiling) @@ -350,12 +350,12 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau - `events/subscribe` is ONLY used for webhook delivery. Poll and push do not need it. - `delivery.secret` is REQUIRED. The client supplies the HMAC signing secret; the server never generates one. The value MUST be a Standard Webhooks symmetric secret: the literal prefix `whsec_` followed by base64 of 24–64 random bytes. Servers MUST reject values that do not satisfy this with `InvalidParams`. Client SDKs SHOULD generate the secret on the application's behalf rather than expose an interface that encourages hand-picked values. -- `id` (response only) is a server-derived handle for the subscription, deterministic over `(principal, delivery.url, name, params)`. The server returns it so the receiver can route deliveries (it appears in the `X-MCP-Subscription-Id` header on every POST). It is stable across refreshes and server restarts. The client does not generate or send it. +- `id` (response only) is a server-derived handle for the subscription, deterministic over `(principal, delivery.url, name, arguments)`. The server returns it so the receiver can route deliveries (it appears in the `X-MCP-Subscription-Id` header on every POST). It is stable across refreshes and server restarts. The client does not generate or send it. - `cursor` (request) tells the server where to begin delivery. `null` means "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 per-subscription cursor position across restarts; it computes a safe-to-persist watermark for inclusion in each delivered payload (see *Webhook Event Delivery*) and returns that same watermark in each subscribe/refresh response so the client's cursor advances during quiet periods. Both sources carry the same value semantics, so the client persists whichever it receives most recently and 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. - `ttlMs` (request, optional) is the client's suggested subscription lifetime; `refreshBefore` (response, always present, nullable) is the server's authoritative grant — an ISO 8601 timestamp indicating when the subscription will expire, or `null` for no expiry. See *Subscription TTL* below for the negotiation rules. Unless granted no expiry, the client MUST re-call `events/subscribe` with the same subscription key before `refreshBefore` to keep the subscription alive; the server re-grants a TTL on each refresh. - `cursor` (response) is the same **safe-to-persist watermark** the server includes in delivery payloads — i.e., a position at or before which everything has been acknowledged or abandoned. Returning it here lets the client's cursor advance during quiet periods when no event POSTs arrive; without this, a long quiet stretch could leave the client holding a cursor older than the upstream's retention window. When deliveries are in flight, the response cursor is at or behind the upstream head (it does not advance past unacked events), so persisting it never skips anything. It is `null` for event types that do not support replay. - `events/subscribe` is 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 (event name, params, callback URL, secret, derived id) with a per-subscription TTL. How durably it holds that state is its own choice, coupled to the TTLs it grants. A server granting short TTLs can keep everything in memory — if it restarts, clients 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, while emit-only event types lose them (see *Emit-only event types*). A server granting long or no-expiry TTLs MUST retain subscriptions for the lifetime it granted, including across restarts (see *Subscription TTL*). +- The server holds subscription state (event name, arguments, callback URL, secret, derived id) with a per-subscription TTL. How durably it holds that state is its own choice, coupled to the TTLs it grants. A server granting short TTLs can keep everything in memory — if it restarts, clients 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, while emit-only event types lose them (see *Emit-only event types*). A server granting long or no-expiry TTLs MUST retain subscriptions for the lifetime it granted, including across restarts (see *Subscription TTL*). #### Subscription TTL @@ -377,11 +377,11 @@ Even with no expiry, clients SHOULD still re-call `events/subscribe` occasionall 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. -**Authentication required.** `events/subscribe` and `events/unsubscribe` MUST be called with an authenticated principal; servers MUST reject calls without an authorized principal with `-32012 Forbidden`. Without a principal in the key, the tuple `(delivery.url, name, params)` is guessable and any caller could unsubscribe or rotate another tenant's secret. Unauthenticated MCP servers may offer poll and push, but not webhook. +**Authentication required.** `events/subscribe` and `events/unsubscribe` MUST be called with an authenticated principal; servers MUST reject calls without an authorized principal with `-32012 Forbidden`. Without a principal in the key, the tuple `(delivery.url, name, arguments)` is guessable and any caller could unsubscribe or rotate another tenant's secret. Unauthenticated MCP servers may offer poll and push, but not webhook. -**Key composition.** The subscription key is `(principal, delivery.url, name, params)`, where `principal` is the server's canonical identifier for the authenticated subject (e.g., OAuth `sub`, API key ID, service-account name). `params` is compared by canonical-JSON equality. There is no client-generated `id` — a subscription is fully determined by what it listens for, where it delivers, and who asked. +**Key composition.** The subscription key is `(principal, delivery.url, name, arguments)`, where `principal` is the server's canonical identifier for the authenticated subject (e.g., OAuth `sub`, API key ID, service-account name). `arguments` is compared by canonical-JSON equality. There is no client-generated `id` — a subscription is fully determined by what it listens for, where it delivers, and who asked. -All four components are immutable for the subscription's lifetime: a subscribe call with a different value for any of them addresses a different subscription. To change what a subscription listens for or where it delivers, `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. +All four components are immutable for the subscription's lifetime: a subscribe call with a different value for any of them addresses a different subscription. To change what a subscription listens for or where it delivers, `events/unsubscribe` the old one and `events/subscribe` a new one. This avoids the case where `name`/`arguments` change in place but the upstream listener provisioned by `on_subscribe` remains bound to the old values. **Derived `id`.** The server computes a deterministic `id` over the key (e.g., a truncated SHA-256 of the canonical key serialization) and returns it in the subscribe response. This is a routing handle, not a capability: it appears in `X-MCP-Subscription-Id` on every delivery and MAY be logged. Knowing it does not authorize anything. @@ -394,7 +394,7 @@ On an idempotent subscribe against an existing key, the server updates the remai | TTL | Re-granted. The refresh carries a (possibly different) `ttlMs` suggestion and the server returns a fresh `refreshBefore` under the same rules as the initial subscribe — so a client can lengthen or shorten its refresh cadence, or convert a no-expiry subscription back to a finite one by suggesting a finite `ttlMs`. | | `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 key includes `principal` and `delivery.url`, two distinct tenants subscribing to the same `(name, params)` get distinct subscriptions. A caller who learns another tenant's derived `id` gains nothing — `id` is not accepted as input to any method. +**Cross-tenant isolation.** Because the key includes `principal` and `delivery.url`, two distinct tenants subscribing to the same `(name, arguments)` get distinct subscriptions. A caller who learns another tenant's derived `id` gains nothing — `id` is not accepted as input to any method. #### Webhook Event Delivery @@ -484,7 +484,7 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre **Endpoint verification (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **one of**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**; or (c) prior **out-of-band verification** of the URL by the server through its own mechanism (e.g., registration and confirmation via the server's dashboard). A reachable endpoint that fails to echo yields `-32015 CallbackEndpointError` with `data.reason: "challenge_failed"`; an unreachable one yields the same code with the relevant connection-failure category (`connection_refused`, `timeout`, or `tls_error`). -Verification is cached per `(principal, url)`: a successful handshake or allowlist hit covers that principal's subscriptions to that URL across refreshes and `params`, so varying `params` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. A server that persists no-expiry subscriptions across restarts (see *Subscription TTL*) MUST persist their verification status alongside them — a persisted subscription resumes delivery without a re-subscribe, so there is no later handshake to lean on, and the stored record only exists because verification succeeded. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. +Verification is cached per `(principal, url)`: a successful handshake or allowlist hit covers that principal's subscriptions to that URL across refreshes and `arguments`, so varying `arguments` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. A server that persists no-expiry subscriptions across restarts (see *Subscription TTL*) MUST persist their verification status alongside them — a persisted subscription resumes delivery without a re-subscribe, so there is no later handshake to lean on, and the stored record only exists because verification succeeded. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. **Server identity (optional).** A server MAY additionally sign deliveries and verification POSTs with an asymmetric key — Standard Webhooks `v1a,` (ed25519), appended alongside the `v1,` HMAC — so a gateway can verify *which* server sent a delivery, enforce a known-server allowlist, and retain protection if an HMAC secret leaks. The verifying public key MUST be discovered from an origin the client already authenticates, and never from the challenge or delivery body, which the attacker controls (that would be trust-on-first-use). The discovery mechanism is a SEP-review-time decision contingent on [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) (server cards), still draft: if server cards are published before this extension, the key is carried inline on the server's card (provisionally a `webhooks.signingKeys` member holding an ed25519 JWK set); otherwise it is published as a standalone JWKS document at a well-known path on the server's origin, `/.well-known/mcp-webhook-jwks.json`. Exactly one of these will be normative in the final SEP — they are not two options offered to implementers. Either way the key is fetched from the same origin the gateway connects to for MCP, and is distinct from the OAuth `jwks_uri` ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), which carries the authorization server's token-signing keys, not the MCP server's webhook-signing key. Keys carry a `kid`; rotation publishes the new key alongside the old. Endpoints that do not enforce server identity ignore `v1a,`. @@ -523,13 +523,13 @@ HMAC is verified by application code after the request has been routed. It does "id": 3, "params": { "name": "incident.created", - "params": { "severity": "P1" }, + "arguments": { "severity": "P1" }, "delivery": { "url": "https://proxy.example.com/hooks/client123" } } } ``` -`events/unsubscribe` resolves the subscription using the same key as `events/subscribe` — `(principal, delivery.url, name, params)`. The client supplies `name`, `params`, and `delivery.url`; `principal` comes from the auth layer. The derived `id` is not accepted as input. +`events/unsubscribe` resolves the subscription using the same key as `events/subscribe` — `(principal, delivery.url, name, arguments)`. The client supplies `name`, `arguments`, and `delivery.url`; `principal` comes from the auth layer. The derived `id` is not accepted as input. `events/unsubscribe` is ONLY used for webhook delivery. Poll subscriptions are implicit (stop polling to unsubscribe). Push subscriptions are scoped to the `events/stream` request (close or cancel to unsubscribe). @@ -580,7 +580,7 @@ The SDK computes the `delivery` array in `events/list` responses from the above, input_schema={ ... }, payload_schema={ ... }, ) -async def check_email(context, params, cursor): +async def check_email(context, arguments, cursor): """Called by the SDK for all delivery modes.""" if cursor is None: # Bootstrap: return current position, no events @@ -590,9 +590,9 @@ async def check_email(context, params, cursor): history = await gmail.history().list(startHistoryId=cursor) events = [] for msg in history.messages: - if matches_params(params, msg): + if matches_arguments(arguments, msg): data = {"messageId": msg.id, "from": msg.sender, ...} - if params.get("redact_pii"): + if arguments.get("redact_pii"): data = redact(data) events.append(Event(name="email.received", eventId=msg.id, data=data)) return EventResult(events=events, cursor=history.historyId) @@ -622,23 +622,23 @@ server = MCPServer( **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) -> bool` decides whether the subscription receives the event. If absent, all subscriptions for that event name receive it. - - `transform(ctx, event, params) -> event` shapes the payload for that subscription (e.g., apply `redact_pii`, expand or thin the payload per a subscriber's `expand` param). If absent, the event is delivered as emitted. + - `match(ctx, event, arguments) -> bool` decides whether the subscription receives the event. If absent, all subscriptions for that event name receive it. + - `transform(ctx, event, arguments) -> event` shapes the payload for that subscription (e.g., apply `redact_pii`, expand or thin the payload per a subscriber's `expand` param). If absent, the event is delivered as emitted. - `ctx` carries the subscription's principal and request metadata. Because param semantics are author-defined (filters, globs, transforms — see `inputSchema`), the SDK cannot evaluate them generically; these hooks let the author own both filtering and shaping. The SDK applies the same hooks when an `events/poll` request 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 — `match` filters by `severity`, `transform` redacts fields per subscriber). + `ctx` carries the subscription's principal and request metadata. Because argument semantics are author-defined (filters, globs, transforms — see `inputSchema`), the SDK cannot evaluate them generically; these hooks let the author own both filtering and shaping. The SDK applies the same hooks when an `events/poll` request 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 arguments (e.g., a PagerDuty webhook that fires for every incident — `match` filters by `severity`, `transform` redacts 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. ```python @server.event(name="incident.created", ...) class IncidentCreated: @staticmethod - def match(ctx: Context, event: Event, params: dict) -> bool: - sev = params.get("severity") + def match(ctx: Context, event: Event, arguments: dict) -> bool: + sev = arguments.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"): + def transform(ctx: Context, event: Event, arguments: dict) -> Event: + if arguments.get("redact_pii"): return event.replace(data={**event.data, "reporter": None}) return event @@ -691,19 +691,19 @@ For servers wrapping upstreams that *do* offer a durable cursor (Gmail historyId ```python @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"]) +async def on_subscribe(context, arguments, subscription_id): + """Set up upstream listener for this subscription's arguments.""" + await slack.join_channel(arguments["channel"]) @server.on_unsubscribe("slack.message") -async def on_unsubscribe(context, params, subscription_id): +async def on_unsubscribe(context, arguments, subscription_id): """Tear down upstream listener.""" - await slack.leave_channel(params["channel"]) + await slack.leave_channel(arguments["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 the subscription limit (`ResourceExhausted`) 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-or-null, eventName, canonicalHash(params))` — on unauthenticated servers all callers share one lease per `(name, params)`. `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 `nextPollMs` so that a well-behaved client never lapses between polls: +**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-or-null, eventName, canonicalHash(arguments))` — on unauthenticated servers all callers share one lease per `(name, arguments)`. `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 `nextPollMs` so that a well-behaved client never lapses between polls: ```python server = MCPServer( @@ -715,7 +715,7 @@ This lease tracking is soft state the SDK maintains on the server author's behal ## 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: +Event names and arguments 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: ```python client = MCPClient( @@ -729,7 +729,7 @@ client = MCPClient( # 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}, + arguments={"from": "*@anthropic.com", "redact_pii": True}, ) # Receive events @@ -756,13 +756,13 @@ If none of the modes the server lists for that event type is usable by this clie ```python # Force poll -sub = await client.subscribe("email.received", params={...}, delivery="poll") +sub = await client.subscribe("email.received", arguments={...}, delivery="poll") # Force push (error if server doesn't support it) -sub = await client.subscribe("email.received", params={...}, delivery="push") +sub = await client.subscribe("email.received", arguments={...}, delivery="push") # Force webhook (error if WebhookConfig not set or server doesn't support it) -sub = await client.subscribe("incident.created", params={...}, delivery="webhook") +sub = await client.subscribe("incident.created", arguments={...}, delivery="webhook") ``` ### How Each Mode Works @@ -818,7 +818,7 @@ This reduces PII exposure in event infrastructure (logs, queues, buffers) and li ### Authorization -**Subscribe-time:** When the caller is authenticated, the server MUST verify the principal has permission to subscribe to the requested event type with the given params (e.g., a Slack server verifies the user has access to the specified channel). Unauthenticated servers — which may offer poll and push but not webhook — apply whatever server-side policy they choose, including accepting all subscriptions. +**Subscribe-time:** When the caller is authenticated, the server MUST verify the principal has permission to subscribe to the requested event type with the given arguments (e.g., a Slack server verifies the user has access to the specified channel). Unauthenticated servers — which may offer poll and push but not webhook — apply whatever server-side policy they choose, including accepting all subscriptions. **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: @@ -863,7 +863,7 @@ The spec does not mandate specific governance mechanisms but is designed to enab - **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`. +- **Rich query language for arguments.** Arguments are simple key-value. No CEL, JSONPath, or complex predicates. Servers define their own argument 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`. @@ -872,13 +872,13 @@ The spec does not mandate specific governance mechanisms but is designed to enab 1. **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. -2. **Should resource subscriptions and `list_changed` notifications be re-expressed as events?** The existing `resources/subscribe` → `notifications/resources/updated` flow and the `notifications/{tools,resources,prompts}/list_changed` family are effectively a special-cased push-only event channel. Folding them into this primitive (e.g., a reserved `mcp.resource.updated` event type whose params are `{uri}`, and `mcp.{tools,resources,prompts}.list_changed` event 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 an `mcp.*` event-name prefix at all. +2. **Should resource subscriptions and `list_changed` notifications be re-expressed as events?** The existing `resources/subscribe` → `notifications/resources/updated` flow and the `notifications/{tools,resources,prompts}/list_changed` family are effectively a special-cased push-only event channel. Folding them into this primitive (e.g., a reserved `mcp.resource.updated` event type whose arguments are `{uri}`, and `mcp.{tools,resources,prompts}.list_changed` event 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 an `mcp.*` event-name prefix at all. 3. **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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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. 4. **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_changed` and `k8s.oom_killed` opens 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's `name` to 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. -5. **Should MCP task state changes be exposed as events?** Tasks are MCP's long-running-operation primitive; clients currently learn of state transitions via push notifications on a live connection. Exposing a reserved `mcp.task.updated` event type (params `{taskId}`, payload `{status, progress?, result?}`) would let a client that started a task and disconnected receive the completion via poll or webhook — useful for "kick off a long job, close the laptop, get notified when done." Same considerations as question 2: reserved `mcp.*` prefix, migration path, and whether one mechanism subsuming another is worth the coupling. +5. **Should MCP task state changes be exposed as events?** Tasks are MCP's long-running-operation primitive; clients currently learn of state transitions via push notifications on a live connection. Exposing a reserved `mcp.task.updated` event type (arguments `{taskId}`, payload `{status, progress?, result?}`) would let a client that started a task and disconnected receive the completion via poll or webhook — useful for "kick off a long job, close the laptop, get notified when done." Same considerations as question 2: reserved `mcp.*` prefix, migration path, and whether one mechanism subsuming another is worth the coupling. ## Note on Consistency and Ordering @@ -927,7 +927,7 @@ These are the choices where reasonable alternatives existed; recorded so reviewe | Server identity to the endpoint | Optional asymmetric signing (Standard Webhooks `v1a,`); verifying key discovered from an origin the client already authenticates — the SEP-2127 server card (inline `webhooks.signingKeys` JWK set) if it lands before this extension, otherwise a standalone `/.well-known/mcp-webhook-jwks.json` document (one chosen at SEP-review time, not both offered) | Symmetric HMAC can't prove *which* server sent a delivery (the secret is shared with the endpoint), so gateways that allowlist known servers need asymmetric signatures. Optional to avoid imposing key management on simple/serverless servers. The key must come from an origin the client already authenticates, not the delivery, else the allowlist is trust-on-first-use. | | Multimedia / high-throughput streams | Out of scope | Streaming media has fundamentally different transport, framing, and back-pressure requirements. Designing for human/automation-scale event rates keeps the SDK surface tractable; a media primitive would be a separate proposal. | | Reliable / ordered delivery | Supported (via opaque cursors + `eventId` dedup), not mandated | Genuine use cases need it, so the cursor abstraction makes at-least-once-with-replay achievable when the upstream is durable. But many upstreams are push-only with no history; mandating reliability would force them to fake it. Servers may return `cursor: null` to opt out. | -| Webhook subscription identity | `(principal, delivery.url, name, params)`; webhook requires auth; server-derived `id` for routing only | The tuple is everything that semantically defines a subscription, so subscribe is naturally idempotent and there is nothing for the client to generate or persist. The derived `id` is a non-capability routing handle. Webhook requires an authenticated principal so the tuple is not guessable by other tenants; without it, anyone could unsubscribe or rotate another tenant's secret. Server-generated and client-generated `id` schemes were considered; both add state or entropy obligations the tuple makes unnecessary. | +| Webhook subscription identity | `(principal, delivery.url, name, arguments)`; webhook requires auth; server-derived `id` for routing only | The tuple is everything that semantically defines a subscription, so subscribe is naturally idempotent and there is nothing for the client to generate or persist. The derived `id` is a non-capability routing handle. Webhook requires an authenticated principal so the tuple is not guessable by other tenants; without it, anyone could unsubscribe or rotate another tenant's secret. Server-generated and client-generated `id` schemes were considered; both add state or entropy obligations the tuple makes unnecessary. | | Server-side subscription durability | Server-controlled via granted TTL — push is connection-scoped, poll is per-request, webhook durability is coupled to the TTLs the server grants | Clients hold the canonical subscription list and reconstruct server state by re-subscribing. A server that wants soft in-memory state grants short TTLs: a crash never orphans a client, a vanished client never leaks resources. A server granting long or no-expiry TTLs opts into persisting subscriptions for the granted lifetime. Trade-off: no server-side "list all subscriptions" without per-principal enumeration. | | Webhook TTL negotiation | Client suggests `ttlMs`; server grant (`refreshBefore`) is authoritative and SHOULD only shorten, except clamping up to a server minimum; `ttlMs: null` may be granted as no expiry | The client knows its own lifetime best — a long-lived tenant holding many subscriptions shouldn't be forced into a server-invented refresh cadence (SIP `Expires` / WebSub `lease_seconds` precedent). The ≤ rule keeps the server from delivering past the client's expected lifetime; the floor exception protects servers from refresh storms. No expiry is offered explicitly rather than excluded: a one-year TTL is practically unlimited anyway, and being explicit lets the spec attach the real obligations (durable state, failure-based GC, no expectation of unsubscribe) to the server that grants it. | | Webhook callback TLS | `https` is MUST | Both event payloads and the `delivery.secret` (in the subscribe request) would otherwise transit in cleartext. No legitimate remote-server deployment lacks TLS in 2026. | @@ -935,7 +935,7 @@ These are the choices where reasonable alternatives existed; recorded so reviewe | Cursor advancement during quiet periods | Heartbeat carries cursor (push); refresh response carries cursor (webhook); poll already returns cursor on empty results | Without this, a long quiet stretch leaves the client holding a cursor older than the upstream's retention window, yielding a spurious `truncated: true` even though nothing happened. Reuses existing periodic messages; no new traffic. | | `eventId` source | Server-assigned; SHOULD be the upstream's stable identifier | Dual-path delivery (e.g., webhook emit + poll backfill) of the same upstream event must collapse under client-side dedup. SDK auto-generates only when the author supplies none. | | Protocol-level flow control | None in v1 | The dominant bottleneck is LLM inference (seconds–minutes per event), not delivery. TCP back-pressure, `nextPollMs`, and client-side prioritization are sufficient for the target event rates. Credit-based control can be added later without breaking changes. | -| New primitive vs extending `resources/subscribe` | New `events/*` primitive | Resources are content-addressed (URI → representation) without parameters or cursors; bolting filter params, replay, and three delivery modes onto them would distort that model more than a parallel primitive costs. Folding `resources/subscribe` into events later is left as an open question. | +| New primitive vs extending `resources/subscribe` | New `events/*` primitive | Resources are content-addressed (URI → representation) without parameters or cursors; bolting filter arguments, replay, and three delivery modes onto them would distort that model more than a parallel primitive costs. Folding `resources/subscribe` into events later is left as an open question. | | Error code surface | Five general-purpose codes (`NotFound`, `Forbidden`, `ResourceExhausted`, `Unsupported`, `CallbackEndpointError`) carrying specifics in typed `data`, plus base `InvalidParams` — not events-specific names | An earlier draft minted seven events-scoped codes (`EventNotFound`, `TooManySubscriptions`, `EndpointVerificationFailed`, `InvalidCallbackUrl`, `SubscriptionNotFound`, `DeliveryModeUnsupported`). Collapsing each family behind one general code with a typed `data` discriminator (the `-32003`/`-32004`/`-32042` pattern) keeps the shared `[-32000,-32099]` range uncluttered and lets future SEPs reuse rather than re-mint, while clients still distinguish every case they act on (by code, by method, or by `data`). Static request errors fold into `InvalidParams`. Defined here pending promotion to the base MCP error registry. | ## Appendix: End-to-End Example — GitHub MCP Server (Webhook Delivery) @@ -954,8 +954,8 @@ sequenceDiagram rect rgb(245,245,250) Note over SDK,Redis: Subscribe (webhook delivery) - SDK->>Server: events/subscribe
{name: "pull_request.opened", params: {repo: "acme/webapp"},
delivery: {mode: "webhook", url: "https://proxy/.../hooks", secret: "whsec_..."}} - Server->>Redis: SETEX sub:{derivedId} {ttl}
{principal, name, params, url, secret} + SDK->>Server: events/subscribe
{name: "pull_request.opened", arguments: {repo: "acme/webapp"},
delivery: {mode: "webhook", url: "https://proxy/.../hooks", secret: "whsec_..."}} + Server->>Redis: SETEX sub:{derivedId} {ttl}
{principal, name, arguments, url, secret} Server->>GH: ensure repo webhook registered
(server-internal, idempotent) Server-->>SDK: {id, refreshBefore, cursor, truncated} end From 2d798bfeab972b7eb22d11d1df81c4421118ed59 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Mon, 15 Jun 2026 12:38:01 +0100 Subject: [PATCH 17/25] Clarify that an absent cursor is equivalent to cursor: null Cursor Lifecycle now states that omitting the cursor field is identical to sending an explicit null in requests, responses, and delivered payloads, and that neither side may fail on omission. This lets emit-only servers never emit the field and lets clients with nothing persisted omit it. --- docs/design-sketch-proposal.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index a4b50af..f623441 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -547,6 +547,8 @@ Cursors are opaque strings managed by the server. They represent a position in t **Initial cursor:** Passing `cursor: null` in any mode means "start from now." The server returns a fresh cursor representing the current position (or `null` if the event type does not support replay). No historical events are replayed. +**Absent means `null`.** `cursor` is optional and nullable everywhere it appears — requests, responses, and delivered payloads. An absent `cursor` field MUST be treated identically to an explicit `cursor: null`: a sender MAY omit the field instead of writing `null`, and a receiver MUST NOT fail because it is missing. This holds in both directions for both SDKs, so an emit-only server with no replay history can simply never emit the field — and a client that has nothing persisted can omit it on subscribe/poll — and both remain conformant without special-casing the field just to write an explicit `null`. + **Cursor advancement without events.** The client's persisted cursor must advance during quiet periods so it doesn't fall outside the upstream's retention window when nothing is happening. Poll covers this inherently (every response carries `cursor`, including when `events: []`). For push, the heartbeat carries `cursor` (see *Heartbeat* under Push-Based Delivery). For webhook, each `events/subscribe` refresh response carries `cursor`. In all three the value is safe to persist (for webhook it is the same watermark as in delivery payloads, never ahead of unacked events); the client persists it exactly as it would a cursor from a delivered event. **Bounding replay with `maxAgeMs`.** All three modes accept an optional `maxAgeMs` (integer milliseconds) alongside `cursor`. When present, the server begins replay from whichever is *later*: the supplied `cursor`, or `now − maxAgeMs`. This lets a client resubscribe with a stale cursor without receiving an unbounded backlog — e.g., a host that was offline for a week can pass its persisted cursor with `maxAgeMs: 300000` to get at most the last five minutes. If the floor advances past the cursor, the server SHOULD set `truncated: true` on the first response (poll result / `notifications/events/active` / webhook subscribe response) so the client knows older events were skipped. Servers whose upstream supports time-addressed reads (e.g., Gmail `after:`, Slack `oldest=`, Kafka `offsetsForTimes`) SHOULD seek directly; others MAY honour `maxAgeMs` by replaying from `cursor` and discarding events whose `timestamp` is older than the floor — or, if that would be prohibitively expensive (e.g., a week-old cursor with `maxAgeMs: 300000`), MAY instead reset to now and return `truncated: true`, which yields the same client-visible outcome without the wasted scan. Servers MAY also apply their own replay ceiling independent of `maxAgeMs` and MUST signal it via `truncated: true` when they do. `maxAgeMs` is ignored when `cursor` is `null` (null already means "now"; replay from before the subscription's start is out of scope per *What Is NOT in v1*) and for event types that do not support replay. From 905ade36a28154731f4a9701544a64676393293f Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Mon, 15 Jun 2026 12:39:12 +0100 Subject: [PATCH 18/25] Bound webhook retries and add a per-delivery no-retry signal Webhook retries were open-ended exponential backoff. Servers should now cap both attempt count and elapsed retry window (illustrative: 3-5 attempts within 10-15 minutes); exhausted events are abandoned for watermark purposes and recoverable via cursor replay when the upstream is durable. A receiver can respond 410 Gone to suppress retries for a delivery it deliberately rejects, treated like 413 as non-retryable without affecting the subscription. --- docs/design-sketch-proposal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index f623441..d81296e 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -426,7 +426,7 @@ X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e - Deliveries follow the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) signature scheme. `webhook-id` carries the `eventId` for event deliveries (per-delivery, used for dedup; control envelopes use `msg__` — see *Non-event webhook bodies*). `webhook-timestamp` is the Unix timestamp (seconds) of the request. `webhook-signature` is `v1,` where the value is `HMAC-SHA256(secret, webhook-id + "." + webhook-timestamp + "." + body)`; multiple space-delimited signatures MAY be present during secret rotation. `X-MCP-Subscription-Id` is an MCP-specific header (not part of Standard Webhooks) carrying the subscription `id` so the receiver can select the correct secret before parsing the body. The receiver MUST verify the signature before processing, SHOULD reject deliveries where `webhook-timestamp` is more than 5 minutes old, and SHOULD deduplicate on `webhook-id`. Each retry attempt regenerates the timestamp and signature. - `eventId` in the body enables idempotent processing. The receiver SHOULD deduplicate by this value. - `cursor` in 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 include `cursor_N` in event N's payload until events at positions `< N` have been acked or given up on. Computing this requires the server to hold, per active subscription, the upstream position and ack status of in-flight events; this is in-memory TTL-scoped state (lost on restart, reconstructed by client refresh), but it is per-subscription bookkeeping the SDK must implement. The client persists every cursor it receives; the most recently received value is always safe to supply on refresh. The server MAY send `cursor: null` for event types that do not support replay (see *Cursor Lifecycle*); in that case there is no recovery point to persist. The endpoint MUST make `cursor` and `eventId` available 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-`2xx` responses. 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 uses `eventId` for deduplication and `timestamp` for ordering if needed. Because the payload `cursor` is a watermark (not the event's own position), out-of-order arrival does not cause the client to persist an unsafe cursor. +- **Delivery model.** The server retries each event independently with exponential backoff on non-`2xx` responses. 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 uses `eventId` for deduplication and `timestamp` for ordering if needed. Because the payload `cursor` is a watermark (not the event's own position), out-of-order arrival does not cause the client to persist an unsafe cursor. Retries are bounded: servers SHOULD cap both the attempt count and the elapsed retry window (for example, 3–5 attempts spread over no more than 10–15 minutes) rather than retrying indefinitely; an event whose retries are exhausted is abandoned for watermark purposes (see the `cursor` note above) and, where the upstream is durable, recoverable via cursor replay. A receiver that intentionally rejects a delivery and does not want it retried — for example because it recognises the event as stale or already processed — responds `410 Gone`; the server MUST treat `410` as a non-retryable failure for that delivery (like `413`, see *Delivery profile*), without affecting the subscription itself. - **Acknowledgement semantics.** A `2xx` response from the webhook endpoint signals that the event has been accepted and the server need not retry it. The endpoint SHOULD NOT return `2xx` until 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.** Because the secret is client-supplied, the receiver can verify the very first delivery — there is no window where a delivery arrives before the receiver knows the secret. A receiver that gets a delivery for an `id` it has not yet been told to route (e.g., the subscribe response has not propagated to the gateway) SHOULD return a retryable status (`503` or `425 Too Early`); the server's retry/backoff redelivers once the receiver is ready. `eventId` deduplication 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 (sets `active: true`) and the server resumes retrying pending events; if the client never refreshes, the subscription expires naturally at TTL. A no-expiry subscription has no such backstop: the server MAY drop it after sustained delivery failure (server-defined window) and SHOULD attempt a `terminated` envelope when it does (see *Subscription TTL*). From b506e34788536b54476cb74e87c4ee37b34da7eb Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Mon, 15 Jun 2026 12:39:39 +0100 Subject: [PATCH 19/25] Add acknowledgment timeout guidance for webhook deliveries States that the wait for a 2xx is server-defined rather than a protocol parameter, with ~5 seconds noted as a common choice, and that endpoints should durably accept the event, ack quickly, and process asynchronously instead of holding the delivery request open. --- docs/design-sketch-proposal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index d81296e..bdd70f8 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -427,7 +427,7 @@ X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e - `eventId` in the body enables idempotent processing. The receiver SHOULD deduplicate by this value. - `cursor` in 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 include `cursor_N` in event N's payload until events at positions `< N` have been acked or given up on. Computing this requires the server to hold, per active subscription, the upstream position and ack status of in-flight events; this is in-memory TTL-scoped state (lost on restart, reconstructed by client refresh), but it is per-subscription bookkeeping the SDK must implement. The client persists every cursor it receives; the most recently received value is always safe to supply on refresh. The server MAY send `cursor: null` for event types that do not support replay (see *Cursor Lifecycle*); in that case there is no recovery point to persist. The endpoint MUST make `cursor` and `eventId` available 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-`2xx` responses. 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 uses `eventId` for deduplication and `timestamp` for ordering if needed. Because the payload `cursor` is a watermark (not the event's own position), out-of-order arrival does not cause the client to persist an unsafe cursor. Retries are bounded: servers SHOULD cap both the attempt count and the elapsed retry window (for example, 3–5 attempts spread over no more than 10–15 minutes) rather than retrying indefinitely; an event whose retries are exhausted is abandoned for watermark purposes (see the `cursor` note above) and, where the upstream is durable, recoverable via cursor replay. A receiver that intentionally rejects a delivery and does not want it retried — for example because it recognises the event as stale or already processed — responds `410 Gone`; the server MUST treat `410` as a non-retryable failure for that delivery (like `413`, see *Delivery profile*), without affecting the subscription itself. -- **Acknowledgement semantics.** A `2xx` response from the webhook endpoint signals that the event has been accepted and the server need not retry it. The endpoint SHOULD NOT return `2xx` until 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. +- **Acknowledgement semantics.** A `2xx` response from the webhook endpoint signals that the event has been accepted and the server need not retry it. The endpoint SHOULD NOT return `2xx` until 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. How long the server waits for that response before treating the attempt as failed (`lastError: "timeout"`) and retrying is a server implementation detail, not a protocol parameter — a timeout on the order of 5 seconds is common. Endpoints SHOULD therefore respond quickly: durably accept the event (persist or enqueue it), return `2xx`, and do any heavier processing asynchronously rather than holding the delivery request open. - **Subscribe/delivery race.** Because the secret is client-supplied, the receiver can verify the very first delivery — there is no window where a delivery arrives before the receiver knows the secret. A receiver that gets a delivery for an `id` it has not yet been told to route (e.g., the subscribe response has not propagated to the gateway) SHOULD return a retryable status (`503` or `425 Too Early`); the server's retry/backoff redelivers once the receiver is ready. `eventId` deduplication 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 (sets `active: true`) and the server resumes retrying pending events; if the client never refreshes, the subscription expires naturally at TTL. A no-expiry subscription has no such backstop: the server MAY drop it after sustained delivery failure (server-defined window) and SHOULD attempt a `terminated` envelope when it does (see *Subscription TTL*). From b8f75563af666d8d706dd32fe95effb478f0cdc5 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Mon, 15 Jun 2026 12:40:06 +0100 Subject: [PATCH 20/25] Add recommended thresholds for suspending failing webhook endpoints The suspension threshold stays server-defined, but the doc now recommends basing it on a sustained failure rate over a meaningful sample (illustrative: above 95% failures over a rolling 60-minute window with at least 100 attempts) so brief blips and low-traffic subscriptions are not suspended prematurely. --- docs/design-sketch-proposal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index bdd70f8..df3210f 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -429,7 +429,7 @@ X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e - **Delivery model.** The server retries each event independently with exponential backoff on non-`2xx` responses. 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 uses `eventId` for deduplication and `timestamp` for ordering if needed. Because the payload `cursor` is a watermark (not the event's own position), out-of-order arrival does not cause the client to persist an unsafe cursor. Retries are bounded: servers SHOULD cap both the attempt count and the elapsed retry window (for example, 3–5 attempts spread over no more than 10–15 minutes) rather than retrying indefinitely; an event whose retries are exhausted is abandoned for watermark purposes (see the `cursor` note above) and, where the upstream is durable, recoverable via cursor replay. A receiver that intentionally rejects a delivery and does not want it retried — for example because it recognises the event as stale or already processed — responds `410 Gone`; the server MUST treat `410` as a non-retryable failure for that delivery (like `413`, see *Delivery profile*), without affecting the subscription itself. - **Acknowledgement semantics.** A `2xx` response from the webhook endpoint signals that the event has been accepted and the server need not retry it. The endpoint SHOULD NOT return `2xx` until 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. How long the server waits for that response before treating the attempt as failed (`lastError: "timeout"`) and retrying is a server implementation detail, not a protocol parameter — a timeout on the order of 5 seconds is common. Endpoints SHOULD therefore respond quickly: durably accept the event (persist or enqueue it), return `2xx`, and do any heavier processing asynchronously rather than holding the delivery request open. - **Subscribe/delivery race.** Because the secret is client-supplied, the receiver can verify the very first delivery — there is no window where a delivery arrives before the receiver knows the secret. A receiver that gets a delivery for an `id` it has not yet been told to route (e.g., the subscribe response has not propagated to the gateway) SHOULD return a retryable status (`503` or `425 Too Early`); the server's retry/backoff redelivers once the receiver is ready. `eventId` deduplication 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 (sets `active: true`) and the server resumes retrying pending events; if the client never refreshes, the subscription expires naturally at TTL. A no-expiry subscription has no such backstop: the server MAY drop it after sustained delivery failure (server-defined window) and SHOULD attempt a `terminated` envelope when it does (see *Subscription TTL*). +- After repeated failures (server-defined threshold), the server MAY suspend delivery (`deliveryStatus.active: false`). As implementation guidance, base suspension on a sustained failure rate over a meaningful sample rather than a fixed failure count — for example, a delivery failure rate above 95% over a rolling 60-minute window with a minimum of 100 delivery attempts — so a brief endpoint blip does not suspend a healthy subscription and a low-traffic subscription is not suspended on a handful of failures. A subsequent successful refresh reactivates it (sets `active: true`) and the server resumes retrying pending events; if the client never refreshes, the subscription expires naturally at TTL. A no-expiry subscription has no such backstop: the server MAY drop it after sustained delivery failure (server-defined window) and SHOULD attempt a `terminated` envelope when it does (see *Subscription TTL*). #### Non-event webhook bodies From 21be9c31c9ba4e7eafabcacb09df50c4ac8a90f5 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Mon, 15 Jun 2026 12:41:01 +0100 Subject: [PATCH 21/25] Surface active rate-limiting via deliveryStatus throttled and retryAfterMs deliveryStatus previously only signalled failure-driven suspension, so a client could not tell server-side throttling apart from a quiet stream or a broken endpoint. Refresh responses can now carry throttled: true with an advisory retryAfterMs hint; gap envelopes still cover events skipped outright. --- docs/design-sketch-proposal.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index df3210f..8c9e333 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -476,8 +476,27 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre } ``` +```jsonc +// Subscription being actively rate-limited (deliveries delayed, not failing) +{ + "id": "sub_a3f1c8e2b0d49f7e", + "refreshBefore": "2026-02-19T17:00:00Z", + "cursor": "cursor_xyz", + "truncated": false, + "deliveryStatus": { + "active": true, + "lastDeliveryAt": "2026-02-19T16:28:00Z", + "lastError": null, + "throttled": true, + "retryAfterMs": 60000 + } +} +``` + `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. The same categories also appear as `data.reason` on a `-32015 CallbackEndpointError` returned synchronously from `events/subscribe`. +`throttled` (optional boolean) distinguishes active rate-limiting from failure-driven suspension: `true` means the server is currently limiting outbound deliveries for this subscription — deliveries are being delayed or coalesced, not failing — so the client should not read reduced traffic as "nothing is happening" or as an endpoint problem. `retryAfterMs` (optional integer) accompanies it as a hint for how long the client should expect throttling to last before normal delivery resumes. Both fields are advisory; servers that do not rate-limit outbound deliveries never send them, and a `gap` envelope still covers the case where throttling caused events to be skipped outright. + #### 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](https://www.iana.org/assignments/iana-ipv4-special-registry/) and [IPv6](https://www.iana.org/assignments/iana-ipv6-special-registry/) 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. From 6967f2ba30a7c961b63040fcba0acbb804a344b1 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Mon, 15 Jun 2026 12:41:43 +0100 Subject: [PATCH 22/25] Add recommended TTL grant range for webhook subscriptions Adds guidance that finite grants of a few minutes up to about a day cover most deployments, framed by the asymmetric costs (refresh traffic scales as O(1/TTL), storage and reclamation as O(TTL)), with no-expiry as the right choice beyond a day and minutes-to-an-hour caps for short-lived subscribers. --- docs/design-sketch-proposal.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index 8c9e333..c75c359 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -365,6 +365,8 @@ Every webhook subscription has a lifetime negotiated at subscribe time: the clie - `refreshBefore` (response) is the grant. It SHOULD be less than or equal to the suggestion — the client asked for what it can commit to refreshing, and granting longer risks the server delivering to an endpoint past the client's expected lifetime. The one sanctioned exception is a server-side floor: a server MAY clamp an impractically short suggestion up to its minimum TTL to protect itself from refresh storms. Clamping in either direction is self-announcing — the client reads the granted `refreshBefore` and schedules its refresh loop from that, so a clamped grant is not an error and there is no rejection path for TTL values. - `refreshBefore: null` grants **no expiry**: the subscription persists until `events/unsubscribe` or server-initiated termination. A server MUST NOT return `null` unless the client suggested `ttlMs: null` — no expiry exceeds every finite suggestion. A server unwilling to grant it simply returns a finite `refreshBefore`, which the client treats like any other grant. +**Recommended grants.** For finite TTLs, grants from a few minutes up to about a day cover most deployments. The trade-off is asymmetric: the cost of a short TTL is refresh traffic, which scales as O(1/TTL) and is negligible for any reasonable value, while the cost of a long TTL is subscription storage and slower reclamation of abandoned subscriptions, which scales as O(TTL). Refreshing once a day is not a meaningful burden on clients, and beyond a day the marginal saving in refresh traffic is close to zero while the retention burden keeps growing — a server wanting to grant more than that should usually be granting no expiry and accepting the obligations below, rather than stretching a finite TTL. Servers expecting only short-lived subscribers (e.g., a coding agent following a single CI run) can reasonably cap grants at minutes to an hour; the clamp-up floor described above protects them from refresh storms. + **TTL is the server's resource-control knob.** A server that wants refresh-driven cleanup and in-memory soft state grants short TTLs. A server that grants long TTLs is choosing to hold subscription state for that long, and should hold it durably enough to honour the grant. There is no sharp boundary to defend here: a one-year TTL is, in every practical sense, unlimited, and excluding `null` from the protocol would only invite "999 years." Offering no expiry explicitly lets the spec attach the real obligations to it. A server that grants no expiry accepts that: - the client is not expected to refresh or unsubscribe, ever — keeping the subscription alive is now the server's job, not the client's; From baef671982c30e117ac7b119436a60153ea6405d Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Mon, 15 Jun 2026 12:43:10 +0100 Subject: [PATCH 23/25] Add receiver-published well-known document as a fourth endpoint verification path A callback URL's origin can serve /.well-known/mcp-webhook-receiver.json listing the path prefixes that accept MCP webhook deliveries; control of the origin stands in as proof of intent, acting as a standing self-serve allowlist for gateways and integration platforms that can publish same-origin content. The document is fetched over the same SSRF-hardened path as deliveries and may be cached per origin. The threat model and decisions tables now list all four verification paths. delivery.url remains required on every subscription. --- docs/design-sketch-proposal.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index c75c359..f43fe19 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -503,9 +503,9 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre **SSRF prevention.** The server MUST validate callback URLs. Servers SHOULD reject URLs whose resolved IP is not globally routable per the [IANA IPv4](https://www.iana.org/assignments/iana-ipv4-special-registry/) and [IPv6](https://www.iana.org/assignments/iana-ipv6-special-registry/) 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 (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **one of**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**; or (c) prior **out-of-band verification** of the URL by the server through its own mechanism (e.g., registration and confirmation via the server's dashboard). A reachable endpoint that fails to echo yields `-32015 CallbackEndpointError` with `data.reason: "challenge_failed"`; an unreachable one yields the same code with the relevant connection-failure category (`connection_refused`, `timeout`, or `tls_error`). +**Endpoint verification (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **one of**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**; or (c) prior **out-of-band verification** of the URL by the server through its own mechanism (e.g., registration and confirmation via the server's dashboard); or (d) a **receiver-published well-known document** — the callback URL's `https` origin serves `/.well-known/mcp-webhook-receiver.json` declaring the path prefixes under that origin that accept MCP webhook deliveries (e.g. `{"receivers": ["/hooks/"]}`), and a callback URL covered by it is verified, with control of the origin standing in as the proof of intent. The server fetches the document over the same SSRF-hardened path used for deliveries and MAY cache it per origin for a bounded period (respecting HTTP caching headers). This is a standing, self-serve allowlist for receivers that can publish same-origin static content — typically a gateway or integration platform fronting many subscriptions; receivers whose delivery origin is not something they can publish documents on use (a)–(c). A reachable endpoint that fails to echo yields `-32015 CallbackEndpointError` with `data.reason: "challenge_failed"`; an unreachable one yields the same code with the relevant connection-failure category (`connection_refused`, `timeout`, or `tls_error`). -Verification is cached per `(principal, url)`: a successful handshake or allowlist hit covers that principal's subscriptions to that URL across refreshes and `arguments`, so varying `arguments` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. A server that persists no-expiry subscriptions across restarts (see *Subscription TTL*) MUST persist their verification status alongside them — a persisted subscription resumes delivery without a re-subscribe, so there is no later handshake to lean on, and the stored record only exists because verification succeeded. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. +Verification is cached per `(principal, url)`: a successful handshake, allowlist hit, or well-known match covers that principal's subscriptions to that URL across refreshes and `arguments`, so varying `arguments` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. A server that persists no-expiry subscriptions across restarts (see *Subscription TTL*) MUST persist their verification status alongside them — a persisted subscription resumes delivery without a re-subscribe, so there is no later handshake to lean on, and the stored record only exists because verification succeeded. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. **Server identity (optional).** A server MAY additionally sign deliveries and verification POSTs with an asymmetric key — Standard Webhooks `v1a,` (ed25519), appended alongside the `v1,` HMAC — so a gateway can verify *which* server sent a delivery, enforce a known-server allowlist, and retain protection if an HMAC secret leaks. The verifying public key MUST be discovered from an origin the client already authenticates, and never from the challenge or delivery body, which the attacker controls (that would be trust-on-first-use). The discovery mechanism is a SEP-review-time decision contingent on [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) (server cards), still draft: if server cards are published before this extension, the key is carried inline on the server's card (provisionally a `webhooks.signingKeys` member holding an ed25519 JWK set); otherwise it is published as a standalone JWKS document at a well-known path on the server's origin, `/.well-known/mcp-webhook-jwks.json`. Exactly one of these will be normative in the final SEP — they are not two options offered to implementers. Either way the key is fetched from the same origin the gateway connects to for MCP, and is distinct from the OAuth `jwks_uri` ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), which carries the authorization server's token-signing keys, not the MCP server's webhook-signing key. Keys carry a `kid`; rotation publishes the new key alongside the old. Endpoints that do not enforce server identity ignore `v1a,`. @@ -813,7 +813,7 @@ The threats this extension is designed to defend against, and the mechanism addr | Unauthorized subscription, or continued delivery after access is revoked | Subscribe-time permission check (`-32012 Forbidden`); periodic delivery-time re-verification; a `terminated` envelope ends the subscription when access is lost. *(Authorization)* | | Treating event receipt as authority to act | Action-time authorization — the agent's response to an event goes through normal MCP authorization; receipt grants no privilege. *(Authorization)* | | SSRF / DNS rebinding (server coerced into requesting internal hosts) | Delivery-time IP validation against the IANA special-purpose registries, connecting to the validated IP with the original Host/SNI, and no redirects; optional callback-URL allowlist. *(Webhook Security → SSRF prevention)* | -| Reflection / amplification DDoS (attacker subscribes a victim's URL) | Mandatory intent challenge (or allowlist) before activating delivery, cached per `(principal, url)`; subscribe and per-destination rate limits. *(Endpoint verification)* | +| Reflection / amplification DDoS (attacker subscribes a victim's URL) | Mandatory endpoint verification (challenge handshake, allowlist, out-of-band confirmation, or receiver-published well-known document) before activating delivery, cached per `(principal, url)`; subscribe and per-destination rate limits. *(Endpoint verification)* | | Forged / spoofed deliveries (endpoint deceived into processing fake events) | Per-subscription HMAC (Standard Webhooks `v1,`); receiver verifies the signature before processing. *(Signature scheme)* | | Server impersonation (endpoint cannot tell which server sent a delivery) | Optional asymmetric signing (`v1a,`) with the public key anchored to the server's trusted origin; gateways allowlist known servers. *(Server identity)* | | Replay of captured deliveries | `webhook-timestamp` freshness window (reject > 5 min) and `eventId` deduplication; single-use, short-lived challenge nonce. *(Signature scheme; Endpoint verification)* | @@ -946,7 +946,7 @@ These are the choices where reasonable alternatives existed; recorded so reviewe | Webhook signature scheme | Adopt Standard Webhooks as a profile (`webhook-id`/`-timestamp`/`-signature`, `v1,` HMAC, `whsec_` secrets, multi-sig rotation) | Gets off-the-shelf verifier libraries in most languages and a defined asymmetric (`v1a,`) path for free. Trade-off is a dependency on an external community spec, but it's small, stable, pinnable, and not a one-way door. | | Batched poll/stream | One subscription per `events/poll`/`events/stream` request | Batching is a transport-level concern; HTTP/2 multiplexing makes per-subscription requests cheap. Avoids the protocol complexity batching introduces (coalescing heterogeneous `nextPollMs`, partial-failure result shapes, per-entry error routing, duplicate-id handling). Clients with many subscriptions on HTTP/1.1 should expect to coalesce at the transport, not the protocol. | | Webhook secret generation | Client-supplied, REQUIRED, per subscription (`whsec_` + 24–64 B) | Avoids the post-subscribe write-back race of server-generated per-sub secrets and the out-of-band registration step of per-endpoint secrets. HMAC proves "this delivery is for a subscription *this endpoint* originated" (not merely "this came from the server"), which stops third-party *deception*; third-party *flooding* is closed separately by the mandatory intent challenge (see *Endpoint verification*). | -| Endpoint verification | Mandatory intent challenge (echo a signed nonce) or allowlist before activating delivery; cached per `(principal, url)` | HMAC stops deception but not flooding: an attacker can point a subscription at a victim's URL and the victim still receives every POST. A consent handshake (the WebSub/SNS/EventGrid pattern) bounds attacker-induced traffic to one POST per `(principal, victim-url)`; per-`(principal, url)` scoping stops one principal's verification waiving the challenge for another. | +| Endpoint verification | Mandatory proof of the endpoint's intent to receive before activating delivery — challenge handshake (echo a signed nonce), server-configured allowlist, prior out-of-band verification, or a receiver-published `/.well-known/mcp-webhook-receiver.json`; cached per `(principal, url)` | HMAC stops deception but not flooding: an attacker can point a subscription at a victim's URL and the victim still receives every POST. A consent handshake (the WebSub/SNS/EventGrid pattern) bounds attacker-induced traffic to one POST per `(principal, victim-url)`; per-`(principal, url)` scoping stops one principal's verification waiving the challenge for another. The allowlist, out-of-band, and well-known paths cover receivers for which a runtime handshake is awkward (dashboard-registered apps, gateways that can prove origin control). | | Server identity to the endpoint | Optional asymmetric signing (Standard Webhooks `v1a,`); verifying key discovered from an origin the client already authenticates — the SEP-2127 server card (inline `webhooks.signingKeys` JWK set) if it lands before this extension, otherwise a standalone `/.well-known/mcp-webhook-jwks.json` document (one chosen at SEP-review time, not both offered) | Symmetric HMAC can't prove *which* server sent a delivery (the secret is shared with the endpoint), so gateways that allowlist known servers need asymmetric signatures. Optional to avoid imposing key management on simple/serverless servers. The key must come from an origin the client already authenticates, not the delivery, else the allowlist is trust-on-first-use. | | Multimedia / high-throughput streams | Out of scope | Streaming media has fundamentally different transport, framing, and back-pressure requirements. Designing for human/automation-scale event rates keeps the SDK surface tractable; a media primitive would be a separate proposal. | | Reliable / ordered delivery | Supported (via opaque cursors + `eventId` dedup), not mandated | Genuine use cases need it, so the cursor abstraction makes at-least-once-with-replay achievable when the upstream is durable. But many upstreams are push-only with no history; mandating reliability would force them to fake it. Servers may return `cursor: null` to opt out. | From 1590d923f77725b8ed2c9cd9d78b43b6c2de1dab Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Mon, 15 Jun 2026 12:52:54 +0100 Subject: [PATCH 24/25] Reconcile new guidance with surrounding text Follow-ups to keep the new additions consistent with the rest of the doc: - Recommended-grants paragraph no longer implies a server can grant no expiry unprompted; it points long-lived needs at an explicit ttlMs: null request and notes the range is a default, not a ceiling. - The throttled prose drops 'coalesced' and routes outright event-skipping to the gap signal instead of conflating it with throttling. - The well-known verification path states the result is still recorded per (principal, url); only the document fetch is cached per origin. - The bounded retry window pauses while delivery is suspended, matching the resume-pending-events behaviour on refresh. - The flow-control note lists the throttled/retryAfterMs visibility hint. - The secret-generation decision row says 'endpoint verification' instead of naming only the challenge; one leftover 'param' becomes 'argument'. --- docs/design-sketch-proposal.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index f43fe19..16b4966 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -365,7 +365,7 @@ Every webhook subscription has a lifetime negotiated at subscribe time: the clie - `refreshBefore` (response) is the grant. It SHOULD be less than or equal to the suggestion — the client asked for what it can commit to refreshing, and granting longer risks the server delivering to an endpoint past the client's expected lifetime. The one sanctioned exception is a server-side floor: a server MAY clamp an impractically short suggestion up to its minimum TTL to protect itself from refresh storms. Clamping in either direction is self-announcing — the client reads the granted `refreshBefore` and schedules its refresh loop from that, so a clamped grant is not an error and there is no rejection path for TTL values. - `refreshBefore: null` grants **no expiry**: the subscription persists until `events/unsubscribe` or server-initiated termination. A server MUST NOT return `null` unless the client suggested `ttlMs: null` — no expiry exceeds every finite suggestion. A server unwilling to grant it simply returns a finite `refreshBefore`, which the client treats like any other grant. -**Recommended grants.** For finite TTLs, grants from a few minutes up to about a day cover most deployments. The trade-off is asymmetric: the cost of a short TTL is refresh traffic, which scales as O(1/TTL) and is negligible for any reasonable value, while the cost of a long TTL is subscription storage and slower reclamation of abandoned subscriptions, which scales as O(TTL). Refreshing once a day is not a meaningful burden on clients, and beyond a day the marginal saving in refresh traffic is close to zero while the retention burden keeps growing — a server wanting to grant more than that should usually be granting no expiry and accepting the obligations below, rather than stretching a finite TTL. Servers expecting only short-lived subscribers (e.g., a coding agent following a single CI run) can reasonably cap grants at minutes to an hour; the clamp-up floor described above protects them from refresh storms. +**Recommended grants.** For finite TTLs, grants from a few minutes up to about a day cover most deployments. The trade-off is asymmetric: the cost of a short TTL is refresh traffic, which scales as O(1/TTL) and is negligible at any reasonable value (the clamp-up floor above exists to exclude unreasonably short ones), while the cost of a long TTL is subscription storage and slower reclamation of abandoned subscriptions, which scales as O(TTL). Refreshing once a day is not a meaningful burden on clients, and beyond a day the marginal saving in refresh traffic is close to zero while the retention burden keeps growing — a subscription that genuinely needs to outlive that is better modelled as an explicit no-expiry request (`ttlMs: null`, with the obligations below) than as a very long finite TTL. This is guidance about sensible defaults, not a ceiling: a client may still suggest more (see `ttlMs` above) and a server MAY honour it. Servers expecting only short-lived subscribers (e.g., a coding agent following a single CI run) can reasonably cap grants at minutes to an hour. **TTL is the server's resource-control knob.** A server that wants refresh-driven cleanup and in-memory soft state grants short TTLs. A server that grants long TTLs is choosing to hold subscription state for that long, and should hold it durably enough to honour the grant. There is no sharp boundary to defend here: a one-year TTL is, in every practical sense, unlimited, and excluding `null` from the protocol would only invite "999 years." Offering no expiry explicitly lets the spec attach the real obligations to it. A server that grants no expiry accepts that: @@ -428,7 +428,7 @@ X-MCP-Subscription-Id: sub_a3f1c8e2b0d49f7e - Deliveries follow the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) signature scheme. `webhook-id` carries the `eventId` for event deliveries (per-delivery, used for dedup; control envelopes use `msg__` — see *Non-event webhook bodies*). `webhook-timestamp` is the Unix timestamp (seconds) of the request. `webhook-signature` is `v1,` where the value is `HMAC-SHA256(secret, webhook-id + "." + webhook-timestamp + "." + body)`; multiple space-delimited signatures MAY be present during secret rotation. `X-MCP-Subscription-Id` is an MCP-specific header (not part of Standard Webhooks) carrying the subscription `id` so the receiver can select the correct secret before parsing the body. The receiver MUST verify the signature before processing, SHOULD reject deliveries where `webhook-timestamp` is more than 5 minutes old, and SHOULD deduplicate on `webhook-id`. Each retry attempt regenerates the timestamp and signature. - `eventId` in the body enables idempotent processing. The receiver SHOULD deduplicate by this value. - `cursor` in 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 include `cursor_N` in event N's payload until events at positions `< N` have been acked or given up on. Computing this requires the server to hold, per active subscription, the upstream position and ack status of in-flight events; this is in-memory TTL-scoped state (lost on restart, reconstructed by client refresh), but it is per-subscription bookkeeping the SDK must implement. The client persists every cursor it receives; the most recently received value is always safe to supply on refresh. The server MAY send `cursor: null` for event types that do not support replay (see *Cursor Lifecycle*); in that case there is no recovery point to persist. The endpoint MUST make `cursor` and `eventId` available 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-`2xx` responses. 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 uses `eventId` for deduplication and `timestamp` for ordering if needed. Because the payload `cursor` is a watermark (not the event's own position), out-of-order arrival does not cause the client to persist an unsafe cursor. Retries are bounded: servers SHOULD cap both the attempt count and the elapsed retry window (for example, 3–5 attempts spread over no more than 10–15 minutes) rather than retrying indefinitely; an event whose retries are exhausted is abandoned for watermark purposes (see the `cursor` note above) and, where the upstream is durable, recoverable via cursor replay. A receiver that intentionally rejects a delivery and does not want it retried — for example because it recognises the event as stale or already processed — responds `410 Gone`; the server MUST treat `410` as a non-retryable failure for that delivery (like `413`, see *Delivery profile*), without affecting the subscription itself. +- **Delivery model.** The server retries each event independently with exponential backoff on non-`2xx` responses. 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 uses `eventId` for deduplication and `timestamp` for ordering if needed. Because the payload `cursor` is a watermark (not the event's own position), out-of-order arrival does not cause the client to persist an unsafe cursor. Retries are bounded: servers SHOULD cap both the attempt count and the elapsed retry window (for example, 3–5 attempts spread over no more than 10–15 minutes; time spent with delivery suspended — see below — pauses rather than consumes the window) rather than retrying indefinitely; an event whose retries are exhausted is abandoned for watermark purposes (see the `cursor` note above) and, where the upstream is durable, recoverable via cursor replay. A receiver that intentionally rejects a delivery and does not want it retried — for example because it recognises the event as stale or already processed — responds `410 Gone`; the server MUST treat `410` as a non-retryable failure for that delivery (like `413`, see *Delivery profile*), without affecting the subscription itself. - **Acknowledgement semantics.** A `2xx` response from the webhook endpoint signals that the event has been accepted and the server need not retry it. The endpoint SHOULD NOT return `2xx` until 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. How long the server waits for that response before treating the attempt as failed (`lastError: "timeout"`) and retrying is a server implementation detail, not a protocol parameter — a timeout on the order of 5 seconds is common. Endpoints SHOULD therefore respond quickly: durably accept the event (persist or enqueue it), return `2xx`, and do any heavier processing asynchronously rather than holding the delivery request open. - **Subscribe/delivery race.** Because the secret is client-supplied, the receiver can verify the very first delivery — there is no window where a delivery arrives before the receiver knows the secret. A receiver that gets a delivery for an `id` it has not yet been told to route (e.g., the subscribe response has not propagated to the gateway) SHOULD return a retryable status (`503` or `425 Too Early`); the server's retry/backoff redelivers once the receiver is ready. `eventId` deduplication and cursor replay make this safe. - After repeated failures (server-defined threshold), the server MAY suspend delivery (`deliveryStatus.active: false`). As implementation guidance, base suspension on a sustained failure rate over a meaningful sample rather than a fixed failure count — for example, a delivery failure rate above 95% over a rolling 60-minute window with a minimum of 100 delivery attempts — so a brief endpoint blip does not suspend a healthy subscription and a low-traffic subscription is not suspended on a handful of failures. A subsequent successful refresh reactivates it (sets `active: true`) and the server resumes retrying pending events; if the client never refreshes, the subscription expires naturally at TTL. A no-expiry subscription has no such backstop: the server MAY drop it after sustained delivery failure (server-defined window) and SHOULD attempt a `terminated` envelope when it does (see *Subscription TTL*). @@ -497,13 +497,13 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre `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. The same categories also appear as `data.reason` on a `-32015 CallbackEndpointError` returned synchronously from `events/subscribe`. -`throttled` (optional boolean) distinguishes active rate-limiting from failure-driven suspension: `true` means the server is currently limiting outbound deliveries for this subscription — deliveries are being delayed or coalesced, not failing — so the client should not read reduced traffic as "nothing is happening" or as an endpoint problem. `retryAfterMs` (optional integer) accompanies it as a hint for how long the client should expect throttling to last before normal delivery resumes. Both fields are advisory; servers that do not rate-limit outbound deliveries never send them, and a `gap` envelope still covers the case where throttling caused events to be skipped outright. +`throttled` (optional boolean) distinguishes active rate-limiting from failure-driven suspension: `true` means the server is currently limiting outbound deliveries for this subscription — deliveries are being delayed, not failing — so the client should not read reduced traffic as "nothing is happening" or as an endpoint problem. `retryAfterMs` (optional integer) accompanies it as a hint for how long the client should expect throttling to last before normal delivery resumes. Both fields are advisory; servers that do not rate-limit outbound deliveries never send them. A server whose protective throttling goes further and skips events outright signals that as a gap (`gap` envelope / `truncated: true` — see *Non-event webhook bodies*), not through these fields. #### 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](https://www.iana.org/assignments/iana-ipv4-special-registry/) and [IPv6](https://www.iana.org/assignments/iana-ipv6-special-registry/) 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 (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **one of**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**; or (c) prior **out-of-band verification** of the URL by the server through its own mechanism (e.g., registration and confirmation via the server's dashboard); or (d) a **receiver-published well-known document** — the callback URL's `https` origin serves `/.well-known/mcp-webhook-receiver.json` declaring the path prefixes under that origin that accept MCP webhook deliveries (e.g. `{"receivers": ["/hooks/"]}`), and a callback URL covered by it is verified, with control of the origin standing in as the proof of intent. The server fetches the document over the same SSRF-hardened path used for deliveries and MAY cache it per origin for a bounded period (respecting HTTP caching headers). This is a standing, self-serve allowlist for receivers that can publish same-origin static content — typically a gateway or integration platform fronting many subscriptions; receivers whose delivery origin is not something they can publish documents on use (a)–(c). A reachable endpoint that fails to echo yields `-32015 CallbackEndpointError` with `data.reason: "challenge_failed"`; an unreachable one yields the same code with the relevant connection-failure category (`connection_refused`, `timeout`, or `tls_error`). +**Endpoint verification (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **one of**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**; or (c) prior **out-of-band verification** of the URL by the server through its own mechanism (e.g., registration and confirmation via the server's dashboard); or (d) a **receiver-published well-known document** — the callback URL's `https` origin serves `/.well-known/mcp-webhook-receiver.json` declaring the path prefixes under that origin that accept MCP webhook deliveries (e.g. `{"receivers": ["/hooks/"]}`), and a callback URL covered by it is verified, with control of the origin standing in as the proof of intent. The server fetches the document over the same SSRF-hardened path used for deliveries and MAY cache the fetched document per origin for a bounded period (respecting HTTP caching headers); the verification it establishes is still recorded per `(principal, url)` like the other paths — the difference is that no challenge POST is needed, because the origin has already published its consent. This is a standing, self-serve allowlist for receivers that can publish same-origin static content — typically a gateway or integration platform fronting many subscriptions; receivers whose delivery origin is not something they can publish documents on use (a)–(c). A reachable endpoint that fails to echo yields `-32015 CallbackEndpointError` with `data.reason: "challenge_failed"`; an unreachable one yields the same code with the relevant connection-failure category (`connection_refused`, `timeout`, or `tls_error`). Verification is cached per `(principal, url)`: a successful handshake, allowlist hit, or well-known match covers that principal's subscriptions to that URL across refreshes and `arguments`, so varying `arguments` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. A server that persists no-expiry subscriptions across restarts (see *Subscription TTL*) MUST persist their verification status alongside them — a persisted subscription resumes delivery without a re-subscribe, so there is no later handshake to lean on, and the stored record only exists because verification succeeded. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. @@ -646,7 +646,7 @@ server = MCPServer( - **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, arguments) -> bool` decides whether the subscription receives the event. If absent, all subscriptions for that event name receive it. - - `transform(ctx, event, arguments) -> event` shapes the payload for that subscription (e.g., apply `redact_pii`, expand or thin the payload per a subscriber's `expand` param). If absent, the event is delivered as emitted. + - `transform(ctx, event, arguments) -> event` shapes the payload for that subscription (e.g., apply `redact_pii`, expand or thin the payload per a subscriber's `expand` argument). If absent, the event is delivered as emitted. `ctx` carries the subscription's principal and request metadata. Because argument semantics are author-defined (filters, globs, transforms — see `inputSchema`), the SDK cannot evaluate them generically; these hooks let the author own both filtering and shaping. The SDK applies the same hooks when an `events/poll` request 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 arguments (e.g., a PagerDuty webhook that fires for every incident — `match` filters by `severity`, `transform` redacts 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. @@ -929,6 +929,7 @@ 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, `nextPollMs` lets the server throttle the client explicitly. +- **Webhook throttling visibility.** A server that rate-limits its outbound webhook deliveries surfaces it via `deliveryStatus.throttled` and `retryAfterMs` on the next refresh (see *Webhook Delivery Status*) — a visibility hint for the client, not a backpressure mechanism. - **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. @@ -945,7 +946,7 @@ These are the choices where reasonable alternatives existed; recorded so reviewe | Mandatory delivery mode | None — servers advertise any non-empty subset of poll/push/webhook | Universal compatibility would be nice, but mandating any single mode places an unacceptable burden on implementers (poll needs a history or buffer; push needs a held connection; webhook needs outbound HTTP + SSRF hardening). The extension is already opt-in. May revisit if deployments converge on a common subset in practice. | | Webhook signature scheme | Adopt Standard Webhooks as a profile (`webhook-id`/`-timestamp`/`-signature`, `v1,` HMAC, `whsec_` secrets, multi-sig rotation) | Gets off-the-shelf verifier libraries in most languages and a defined asymmetric (`v1a,`) path for free. Trade-off is a dependency on an external community spec, but it's small, stable, pinnable, and not a one-way door. | | Batched poll/stream | One subscription per `events/poll`/`events/stream` request | Batching is a transport-level concern; HTTP/2 multiplexing makes per-subscription requests cheap. Avoids the protocol complexity batching introduces (coalescing heterogeneous `nextPollMs`, partial-failure result shapes, per-entry error routing, duplicate-id handling). Clients with many subscriptions on HTTP/1.1 should expect to coalesce at the transport, not the protocol. | -| Webhook secret generation | Client-supplied, REQUIRED, per subscription (`whsec_` + 24–64 B) | Avoids the post-subscribe write-back race of server-generated per-sub secrets and the out-of-band registration step of per-endpoint secrets. HMAC proves "this delivery is for a subscription *this endpoint* originated" (not merely "this came from the server"), which stops third-party *deception*; third-party *flooding* is closed separately by the mandatory intent challenge (see *Endpoint verification*). | +| Webhook secret generation | Client-supplied, REQUIRED, per subscription (`whsec_` + 24–64 B) | Avoids the post-subscribe write-back race of server-generated per-sub secrets and the out-of-band registration step of per-endpoint secrets. HMAC proves "this delivery is for a subscription *this endpoint* originated" (not merely "this came from the server"), which stops third-party *deception*; third-party *flooding* is closed separately by mandatory endpoint verification (see *Endpoint verification*). | | Endpoint verification | Mandatory proof of the endpoint's intent to receive before activating delivery — challenge handshake (echo a signed nonce), server-configured allowlist, prior out-of-band verification, or a receiver-published `/.well-known/mcp-webhook-receiver.json`; cached per `(principal, url)` | HMAC stops deception but not flooding: an attacker can point a subscription at a victim's URL and the victim still receives every POST. A consent handshake (the WebSub/SNS/EventGrid pattern) bounds attacker-induced traffic to one POST per `(principal, victim-url)`; per-`(principal, url)` scoping stops one principal's verification waiving the challenge for another. The allowlist, out-of-band, and well-known paths cover receivers for which a runtime handshake is awkward (dashboard-registered apps, gateways that can prove origin control). | | Server identity to the endpoint | Optional asymmetric signing (Standard Webhooks `v1a,`); verifying key discovered from an origin the client already authenticates — the SEP-2127 server card (inline `webhooks.signingKeys` JWK set) if it lands before this extension, otherwise a standalone `/.well-known/mcp-webhook-jwks.json` document (one chosen at SEP-review time, not both offered) | Symmetric HMAC can't prove *which* server sent a delivery (the secret is shared with the endpoint), so gateways that allowlist known servers need asymmetric signatures. Optional to avoid imposing key management on simple/serverless servers. The key must come from an origin the client already authenticates, not the delivery, else the allowlist is trust-on-first-use. | | Multimedia / high-throughput streams | Out of scope | Streaming media has fundamentally different transport, framing, and back-pressure requirements. Designing for human/automation-scale event rates keeps the SDK surface tractable; a media primitive would be a separate proposal. | From 28ec35e905daa241f019981e2836b4a02f1c0368 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Fri, 4 Sep 2026 16:33:00 +0000 Subject: [PATCH 25/25] Add schema-evolution guidance and termination on event-type removal or breaking change A subscription (especially a no-expiry webhook one) can outlive the events/list response it was created from, and nothing on the wire told the client when its contract went stale. This adds: - a Schema evolution note: inputSchema/payloadSchema SHOULD evolve additively for the lifetime of an event name; breaking changes go under a new name; - an Event Type Removal and Breaking Changes subsection: the server SHOULD end affected push/webhook subscriptions via the existing terminated signal, with NotFound{kind:event} for removal and Unsupported{reason:schema_changed} for in-place change (not Forbidden), and an honest note that poll has no server-side subscription to terminate; - list_changed now explicitly covers descriptor changes, and no-expiry clients are pointed at an occasional events/list alongside their refresh; - a NOT-in-v1 bullet recording that schemaVersion/negotiation and an in-band schema fingerprint are deferred. Also rewords the Streamable HTTP push bullet so it no longer refers to the transport's GET SSE stream, which SEP-2575 removed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Nj99r48USCY7S4xKoHD1Df --- docs/design-sketch-proposal.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index 16b4966..5c74e84 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -90,10 +90,15 @@ Servers advertise event support in their capabilities: - `delivery` lists the delivery modes this event type supports — any non-empty subset of `"poll"`, `"push"`, `"webhook"`. No mode is mandatory. A client that cannot use any of the listed modes cannot subscribe to this event type. - `inputSchema` is a JSON Schema describing valid subscription arguments — these may include filters (which narrow the event stream), transforms (which modify payloads), or other server-defined configuration. This mirrors the `inputSchema`/`arguments` pairing on tools for consistency. - `payloadSchema` describes the shape of `data` in delivered events. +- **Schema evolution.** A subscription can outlive the `events/list` response it was created from, so servers SHOULD evolve an event type's `inputSchema` and `payloadSchema` additively for the lifetime of its `name`: new optional fields MAY be added; existing fields SHOULD NOT be removed, renamed, or retyped; enums SHOULD NOT be narrowed; and `inputSchema` SHOULD NOT be tightened such that previously accepted `arguments` become invalid (a webhook refresh re-sends them and would fail with `-32602`). A breaking change SHOULD instead be published under a new event name, served alongside the old one for a migration period, after which the old name is removed (see *Event Type Removal and Breaking Changes*). ### 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`. +If the set of available event types, or the descriptor of any of them (`description`, `delivery`, `inputSchema`, `payloadSchema`), changes at runtime (e.g., a plugin is loaded, a data source is connected, a schema gains a field), 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`. + +### Event Type Removal and Breaking Changes + +When a server stops offering an event type, or changes its `inputSchema` or `payloadSchema` incompatibly in place (contrary to *Schema evolution* above), existing subscriptions to that name no longer hold a valid contract. The server SHOULD end them using each mode's termination signal (see *Authorization*): `notifications/events/terminated` on push streams and a `terminated` envelope to webhook subscriptions. The `error` is `-32011 NotFound` with `data: {"kind": "event"}` when the type was removed, or `-32014 Unsupported` with `data: {"feature": "payloadSchema" | "inputSchema", "reason": "schema_changed"}` when it was changed in place — not `-32012 Forbidden`, since the principal's access is unchanged — so the client SDK re-fetches `events/list` and resubscribes against the current descriptor rather than treating it as an authorization failure. Purely additive changes MUST NOT terminate subscriptions. Poll holds no server-side subscription to terminate: a poll against a removed name already returns `-32011 NotFound`; for an in-place change, a server that wants to force re-discovery MAY mint cursors that encode a schema epoch and answer a stale one with the same `Unsupported` error, otherwise poll clients pick up the change on their next `events/list`. ## Subscribing and Event Delivery @@ -204,7 +209,7 @@ Push delivery uses a long-lived `events/stream` request — one per subscription The transport mechanism differs by transport type: -- **Streamable HTTP:** The `events/stream` request 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_STREAM` on HTTP/2) — no explicit cancellation message is needed. +- **Streamable HTTP:** The `events/stream` request is a POST that returns an SSE response stream. This stream carries only this subscription's event notifications (`notifications/events/*`); it is not a general server-to-client channel, and other server-initiated notifications (`notifications/*/list_changed`, progress, logging) continue to use whatever mechanism the base transport provides for them. The client cancels by aborting the request stream (TCP close on HTTP/1.1, `RST_STREAM` on HTTP/2) — no explicit cancellation message is needed. - **stdio:** The `events/stream` request 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 sending `notifications/cancelled` with the request's `id`. ```mermaid @@ -260,7 +265,7 @@ If the subscription is invalid (`NotFound`, `Forbidden`, `InvalidParams`, `Unsup {"jsonrpc":"2.0","id":1,"result":{"_meta":{}}} ``` -`notifications/events/error` reports a recoverable failure (e.g., a single upstream fetch failed); the subscription remains active and the server retries and resumes. Only `notifications/events/terminated` (see *Authorization*) ends the subscription. A gap (e.g., the cursor fell outside the upstream's retention window) is *not* an error — the server sends a fresh `notifications/events/active {cursor:, truncated:true, _meta.subscriptionId}` and continues delivering (see *Cursor Lifecycle*). +`notifications/events/error` reports a recoverable failure (e.g., a single upstream fetch failed); the subscription remains active and the server retries and resumes. Only `notifications/events/terminated` ends the subscription (see *Authorization* and *Event Type Removal and Breaking Changes*). A gap (e.g., the cursor fell outside the upstream's retention window) is *not* an error — the server sends a fresh `notifications/events/active {cursor:, truncated:true, _meta.subscriptionId}` and continues delivering (see *Cursor Lifecycle*). 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. @@ -373,7 +378,7 @@ Every webhook subscription has a lifetime negotiated at subscribe time: the clie - restart recovery via the refresh cycle no longer applies — the server MUST persist no-expiry subscriptions across restarts, because a client that never refreshes will never detect (or repair) a silently dropped one; - TTL expiry no longer garbage-collects orphans or dead endpoints — the server MAY drop a no-expiry subscription after sustained delivery failure (server-defined window), and SHOULD attempt a `terminated` envelope when it does. -Even with no expiry, clients SHOULD still re-call `events/subscribe` occasionally: the refresh response is where the cursor advances during quiet periods, where `deliveryStatus` surfaces delivery problems, and where suspended delivery reactivates (see *Webhook Event Delivery*). The difference is that correctness no longer depends on it. +Even with no expiry, clients SHOULD still re-call `events/subscribe` (and `events/list`) occasionally: the refresh response is where the cursor advances during quiet periods, where `deliveryStatus` surfaces delivery problems, and where suspended delivery reactivates (see *Webhook Event Delivery*), and a periodic `events/list` bounds how long a descriptor change can go unnoticed by a client with no live connection. The difference is that correctness no longer depends on it. #### Subscription Identity @@ -890,6 +895,7 @@ The spec does not mandate specific governance mechanisms but is designed to enab - **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`. +- **Event schema versioning.** No `schemaVersion` field on event types or occurrences, and no subscribe-time version negotiation. v1 relies on additive schema evolution plus server-initiated termination on removal or breaking change (see *Schema evolution* and *Event Type Removal and Breaking Changes*). An in-band schema fingerprint (e.g., a `payloadSchema` hash carried in `_meta` on the descriptor and echoed on each `EventOccurrence`) is a possible additive follow-on for clients that want exact drift detection, best revisited once the base protocol settles tool naming and versioning. ## Open Questions