Make webhook TTL client-suggested with explicit no-expiry option
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.
This commit is contained in:
parent
1034fbbc52
commit
99f3589c83
1 changed files with 41 additions and 23 deletions
|
|
@ -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_<base64-of-24-to-64-random-bytes>"
|
||||
},
|
||||
"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":"<nonce>"}`), 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,<br/>subscription gone
|
||||
```
|
||||
|
||||
The Redis lane is illustrative, not normative. The body of this doc says webhook subscriptions are "in memory with TTL"; that is sufficient for a single-process server. A horizontally scaled server (multiple replicas behind a load balancer) needs *shared* state so any replica can match an incoming upstream event to subscriptions created on another replica — Redis with key TTL is the obvious fit, but any shared store with expiry works. The protocol does not require this state to be *durable*: if it is lost, clients re-create it on their next refresh and the client's persisted cursor covers the gap (for event types backed by a durable upstream).
|
||||
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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue