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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
- 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
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.
- 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
- 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
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.
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.