# When must an MCP server refuse to resume a streamable HTTP session after a transport-level reconnect? **Date of Record:** 2026 November 6 **Scope:** Applies to MCP servers exposing the streamable HTTP transport with server-push (SSE-style) event streams and client-driven session resumption via last-event-id / cursor replay. Does not apply to stdio transports, nor to stateless HTTP request/response MCP endpoints that do not emit server-initiated events. ## Core Distinction A resumable session is not the same thing as a *replayable* event log. The streamable HTTP transport allows a client to reconnect and present a resume cursor (last-event-id) so the server can re-emit events the client may have missed. The non-obvious invariant is this: **the resume cursor is only meaningful if the server can prove that every event with an id strictly greater than the cursor and less than or equal to the current head is still present, in order, in the session's retained log.** If the server has pruned, coalesced, or reordered any event in that window — even one that the client never acknowledged — resumption must be *refused*, not silently "filled in" from whatever remains. A gap in the retained log is not a gap the server may paper over. ## What Was Observed During a transport reconnect under load, a client presented a cursor pointing at event `E_1042`. The server's per-session ring buffer had evicted `E_1043` through `E_1051` under memory pressure but retained `E_1052` onward. The server resumed by emitting `E_1052..E_head`, treating the eviction as "the client probably saw them." The client, honoring its own at-least-once contract, had *not* seen `E_1043..E_1051`; those events carried tool-call progress notifications and one `notifications/cancelled` for a request the client was still tracking as live. The result was a session that appeared healthy at the transport layer while the client and server held divergent views of in-flight request state — a split-brain at the protocol layer, invisible to both health checks. The failure was non-obvious because the ring buffer's eviction was *correct* for memory safety, and the resume handler's "emit what we still have" was *locally* reasonable. The bug lived in the seam between them: no component asserted the contiguity invariant across the eviction boundary. ## Condition of Validity Resumption is valid if and only if the retained log for the session forms a contiguous, monotonically increasing sequence from the cursor's successor through the current head, with no evictions inside that interval and no id reuse. Concretely, the server must track, per session, the *lowest retained event id* (`floor_id`) alongside the head. A resume request with cursor `C` is admissible only when `C + 1 >= floor_id` (equivalently `C >= floor_id - 1`). If `C < floor_id - 1`, the requested window intersects evicted history and the invariant is violated. Ids must be per-session monotonic and never recycled across reconnects; a session that is torn down and re-established is a *new* session and must not accept the old cursor. ## Decision Boundary The boundary is the single comparison `cursor < floor_id - 1`. On the admissible side, replay the retained window in order and resume live push. On the inadmissible side, the server must fail the resume explicitly — emit a terminal protocol error indicating `resume_unavailable` with the earliest retained id so the client can decide whether to re-initialize the session — and must not emit any partial window that skips evicted events. The tempting middle path ("replay what we have, let the client reconcile") is precisely the failure mode: it converts a detectable, recoverable protocol error into silent state divergence. The client cannot reconcile what it does not know it missed. ## Operational Effect Refusing resume forces session re-establishment, which is cheap and observable; silently resuming across a gap produces long-lived, hard-to-attribute divergence in request lifecycle state (dangling cancellations, phantom progress, orphaned tool calls). Instrument `floor_id`, head, and cursor at every resume attempt; alert when `resume_unavailable` fires, because it signals either memory pressure evicting live windows or a client reconnect storm. Size the retained window against worst-case reconnect latency, and treat eviction of an unacknowledged event as a hard signal that the retention budget is undersized — not as license to approximate. ```python class ResumeGapError(RuntimeError): """Raised when a resume cursor points into evicted session history.""" def __init__(self, cursor: int, floor_id: int, head_id: int): self.cursor = cursor self.floor_id = floor_id self.head_id = head_id super().__init__( f"resume_unavailable: cursor={cursor} precedes retained floor={floor_id} " f"(head={head_id}); session must be re-established" ) class SessionEventLog: def __init__(self, capacity: int): self.capacity = capacity self._events: list[tuple[int, dict]] = [] self._next_id = 1 @property def floor_id(self) -> int: return self._events[0][0] if self._events else self._next_id @property def head_id(self) -> int: return self._next_id - 1 def append(self, event: dict) -> int: eid = self._next_id self._next_id += 1 self._events.append((eid, event)) if len(self._events) > self.capacity: self._events.pop(0) # eviction is allowed; resume across it is not return eid def resume_from(self, cursor: int) -> list[tuple[int, dict]]: # Invariant: the window (cursor, head] must be fully retained and contiguous. if cursor < self.floor_id - 1: raise ResumeGapError(cursor, self.floor_id, self.head_id) return [(eid, ev) for eid, ev in self._events if eid > cursor] ```