# What happens when an instance acknowledges an SSE event ID it has not yet durably committed: resumability gaps in MCP HTTP-native transport with streamable SSE and session resumption tokens **Date of Record:** 2026 November 14 **Scope:** Applies to MCP servers implementing the HTTP-native transport with streamable SSE (single POST endpoint that upgrades to `text/event-stream`, `Last-Event-ID` resume semantics, and `Mcp-Session-Id` resumption tokens). Does not apply to stdio transports, nor to servers that buffer the full event stream in durable storage before emitting `id:` fields. ## Core Distinction The MCP streamable-HTTP transport treats the SSE `id:` field as a *resumption cursor*, not merely a sequence marker. When a client reconnects with `Last-Event-ID: N`, the server is contractually obligated to replay every event after `N`. The non-obvious boundary is this: **the event ID is an acknowledgment of durability, not of emission.** If the server assigns and emits `id:` before the corresponding message is committed to the backing store (queue, log, or session state), a crash between emission and commit produces a cursor that points past data the server can no longer produce. The client's `Last-Event-ID` is then *ahead* of the server's durable log, and resumption silently drops events. The distinction that matters: emission order ≠ commit order, and the resumption token encodes the former. ## What Was Observed Under a load test with an MCP server fronting a Redis-backed event log, clients received `id: 1041` for a `notifications/tools/list_changed` event. The server process was killed (SIGKILL) 3 ms later, before the `XADD` to the Redis stream completed. On reconnect the client sent `Last-Event-ID: 1041`. The server's stream head was at `1040`. The server, treating `Last-Event-ID` as authoritative, resumed from `1041` and replayed nothing — the notification was lost with no error surfaced to the client. The client's session resumption token remained valid, so no re-initialization handshake occurred. The failure was silent: no 4xx, no gap indicator, no `retry:` hint. A second variant: when the server *did* detect the cursor was ahead of its head (defensive check), several implementations returned `400 Bad Request` on the resume POST, which the client interpreted as a fatal session error and discarded its resumption token entirely — trading a lost event for a lost session. ## Condition of Validity This boundary applies when all of the following hold: (1) the server assigns SSE `id:` values from a monotonically increasing source that is *not* the durable commit itself (e.g., an in-memory counter, a wall-clock, or a pre-allocated range); (2) the write to durable storage is asynchronous relative to the `id:` emission on the wire; (3) the client honors `Last-Event-ID` strictly and does not perform application-level gap detection. It does *not* apply when the event ID is the durable log offset itself (e.g., the Redis stream ID returned by `XADD`, or a Postgres `LSN`), because emission cannot precede commit — the ID does not exist until the write succeeds. It also does not apply to stateless SSE where the server replays from a snapshot rather than a cursor. ## Decision Boundary The correct invariant is: **never emit an `id:` that is not already durable.** Two implementation strategies satisfy it. (A) *Commit-then-emit*: perform the durable write, obtain the storage-assigned offset, and use that offset as the `id:`. This makes `Last-Event-ID` a true log position. Cost: one storage round-trip on the hot path before the client sees the event. (B) *Emit-then-reconcile with gap detection*: emit provisional IDs but have the server, on resume, compare `Last-Event-ID` against its durable head; if the cursor is ahead, respond not with `400` but with a structured resume error that instructs the client to re-fetch state (or replay from the durable head with an explicit `gap: true` marker). The decision boundary: choose (A) when events are state-changing notifications the client cannot reconstruct; choose (B) when events are advisory and the client can tolerate a re-sync. Do **not** choose the common anti-pattern of emit-first + strict `Last-Event-ID` trust, which is the configuration that produces silent loss. ## Operational Effect With commit-then-emit, replay after reconnect is exact and `Last-Event-ID` never exceeds the durable head; the only cost is latency. With emit-then-reconcile, the server must expose the durable head and the client must implement a re-sync path on `gap`. The failure mode to instrument against is a `Last-Event-ID` strictly greater than the server's durable head at resume time — this is always a bug, never a benign race, and should be logged as a resumability violation with the session token, the cursor, and the head. Clients should treat `400` on resume as "re-initialize," not "abandon session," and servers should never discard a valid `Mcp-Session-Id` because of a cursor mismatch. ```python from __future__ import annotations from dataclasses import dataclass class ResumabilityViolation(RuntimeError): """Raised when an SSE resume cursor is not backed by durable state.""" @dataclass(frozen=True) class ResumeCheck: session_id: str last_event_id: int durable_head: int def validate_resume(check: ResumeCheck) -> int: """ Enforce the invariant: Last-Event-ID must be <= durable head. Returns the offset to resume from (exclusive of last_event_id). """ if check.last_event_id > check.durable_head: # The client acknowledged an ID the server never durably committed. # This is a resumability violation, not a client error to be 400'd. raise ResumabilityViolation( f"session={check.session_id} cursor={check.last_event_id} " f"exceeds durable head={check.durable_head}; " "client must re-sync from head, not abandon session" ) if check.last_event_id < 0: raise ResumabilityViolation( f"session={check.session_id} negative cursor={check.last_event_id}" ) return check.last_event_id def assign_event_id(durable_offset: int, emitted: bool) -> int: """ Commit-then-emit: the SSE id MUST be the durable offset. Guards against emitting an id before the write succeeded. """ if not emitted and durable_offset is None: raise ResumabilityViolation( "attempted to emit id before durable commit" ) return durable_offset def resume_or_resync(check: ResumeCheck) -> tuple[int, bool]: """Return (resume_offset, needs_resync).""" try: return validate_resume(check), False except ResumabilityViolation: # Fall back to replaying from durable head with an explicit gap marker. return check.durable_head, True ```