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'.
This commit is contained in:
Peter Alexander 2026-06-15 12:52:54 +01:00
commit 1590d923f7
No known key found for this signature in database

View file

@ -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_<type>_<random>` — see *Non-event webhook bodies*). `webhook-timestamp` is the Unix timestamp (seconds) of the request. `webhook-signature` is `v1,<base64>` 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":"<nonce>"}`), 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":"<nonce>"}`), 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. |