# How should an instance reconcile MCP HTTP-native transport streaming session resumption with backpressure when the client resumes mid-stream **Date of Record:** 2026 November 9 **Scope:** Applies to MCP servers exposing HTTP-native streaming transports (SSE-based and streamable-HTTP) with `Last-Event-ID`-style resumption and per-stream flow control. Does not apply to stdio transports, nor to stateless one-shot JSON request/response endpoints where no resumable event log exists. ## Core Distinction Session resumption and backpressure are governed by two different, easily conflated identifiers and two different notions of "position." Resumption is keyed on the **last event ID the client acknowledged receiving** (`Last-Event-ID` on reconnect). Backpressure is keyed on the **server-side send buffer occupancy per stream**, which is a function of how many events the transport has accepted into the kernel/socket layer minus what the client's TCP receive window has drained. The non-obvious boundary: a client can resume from event ID `N` while the server's per-stream buffer still holds unacknowledged events `N+1 … N+k` that were written before the disconnect. On resume, the server must decide whether to **replay from the event log** (authoritative, ordered, possibly lossy if the log was truncated) or **drain the retained buffer** (cheap, but its head may not align with `N+1` if the buffer was partially flushed). These two sources are not guaranteed to be identical. The invariant that must hold is: > For any resumption at `Last-Event-ID = N`, the sequence of events the client observes after reconnect must be a contiguous, gap-free suffix of the server's authoritative event log starting at `N+1`, regardless of which physical source (retained buffer vs. log replay) supplies each event. Violating this invariant produces a *silent gap*: the client believes it has a continuous stream, but events were skipped or duplicated across the buffer/log seam. This is worse than a hard disconnect because the client's own state machine sees no error. ## What Was Observed In a streamable-HTTP MCP deployment under a slow-consumer test (client reads at ~1/20th server production rate), the following sequence recurred: 1. Server writes events `100..140` to the stream. TCP backpressure stalls after event `118`; events `119..140` sit in the server's application-level buffer. The event log records all of `100..140` durably. 2. Client's connection drops at the transport layer after having received and processed through `118`. Client reconnects with `Last-Event-ID: 118`. 3. Server, on resume, drains the retained application buffer first (fast path) and then continues from the log tail. Because the retained buffer's head was event `119` but the buffer had already been *partially* flushed by the old socket's final ACK-drain, the buffer head had advanced to `124` — events `119..123` were considered "sent" and evicted, but the client never received them (the old socket was reset before delivery was confirmed). 4. Server emitted `124..140` from the buffer, then `141+` from the log. Client observed `118 → 124`, a silent gap of five events. The gap was invisible to both sides: the server saw a successful resume; the client saw a valid SSE stream with monotonically increasing IDs and no error. Only a downstream consistency check (a tool-call sequence counter) surfaced the missing range. ## Condition of Validity The buffer-drain-then-log-tail strategy is valid **only if** the retained buffer is guaranteed to be a contiguous prefix of the authoritative log starting exactly at `Last-Event-ID + 1` at the moment of resume. This holds only when: - The buffer is append-only and eviction happens strictly on *confirmed* delivery (application-level ACK or `SO_SNDBUF`-drain confirmation tied to the event ID), not on write() return; and - The event log retention window covers the entire buffer depth, so any buffer eviction is reconstructible from the log; and - Resume is serialized against in-flight writes for that stream (no concurrent producer appending between buffer snapshot and log-tail read). If any of these is false — and in practice the second and third commonly are, because retention windows are bounded by memory and producers are concurrent — the buffer is **not** an authoritative contiguous prefix and must not be used as the resume source. In that case the only correct source is the event log, replayed from `N+1`, with the retained buffer discarded (it may contain events the log has since truncated, or events out of alignment). ## Decision Boundary On every resume request, the server must classify the stream state: - **Aligned:** buffer head == `Last-Event-ID + 1` AND buffer is confirmed-contiguous with log at that point AND log retention covers `[N+1, tail]`. → Drain buffer, then continue from log tail. Fast path. - **Misaligned but log-covered:** buffer head > `Last-Event-ID + 1` (the observed failure), or buffer head < `Last-Event-ID + 1` (duplicate risk). → Discard buffer, replay from log at `N+1`. - **Log-truncated:** `Last-Event-ID + 1` is older than the log's retained floor. → Cannot honor the invariant. Must fail the resume explicitly rather than silently gap. This is the case where the server *must not* pretend success. The boundary is not "did the client reconnect" but "is the requested resume point still inside a reconstructible, contiguous window." A resume request outside that window is a distinct failure class that requires an explicit protocol error, not a best-effort partial replay. ## Operational Effect When the invariant is honored, slow-consumer streams behave correctly across reconnects: the client's view is always a gap-free suffix of the log, backpressure merely delays delivery without corrupting resumption, and buffer eviction is safe because the log is the single source of truth. The cost is that the fast buffer-drain path is only usable when alignment is provable; misaligned resumes pay full log replay. When it is violated, the failure is silent and corrupting: agents downstream of the client operate on a stream that looks valid but has holes, and the error surfaces far from the transport. The observed five-event gap manifested as a malformed tool-call sequence three layers up. The operational rule: **treat the retained send buffer as a cache, never as a source of truth for resume; and treat an out-of-window resume as an error, never as a soft success.** ```python class ResumeInvariantViolation(RuntimeError): """Raised when a stream resume cannot produce a gap-free suffix from Last-Event-ID.""" pass class EventLog: def __init__(self, retained_floor: int): self._events: dict[int, dict] = {} self._retained_floor = retained_floor def append(self, event_id: int, payload: dict) -> None: self._events[event_id] = payload def tail(self) -> int: return max(self._events) if self._events else 0 def covers(self, start: int) -> bool: return start >= self._retained_floor and start <= self.tail() + 1 def replay_from(self, start: int): for eid in range(start, self.tail() + 1): yield eid, self._events[eid] def resume_stream(log: EventLog, last_event_id: int, buffer_head: int): """Resume a stream at last_event_id+1, honoring the gap-free invariant. buffer_head is the first event ID still present in the retained send buffer, or -1 if the buffer is empty/evicted. """ start = last_event_id + 1 if not log.covers(start): # Resume point is outside the reconstructible window. Do NOT silently # replay a partial range; that manufactures a gap the client cannot see. raise ResumeInvariantViolation( f"cannot resume at {start}: log retained floor is " f"{log._retained_floor}, tail is {log.tail()}" ) # Aligned fast path: buffer head is exactly the next event and contiguous. if buffer_head == start: yield from log.replay_from(start) return # Misaligned (buffer ahead or behind the resume point): discard buffer, # treat log as sole source of truth. yield from log.replay_from(start) ```