# When must an instance treat MCP streamable-session resumption as failed rather than merely delayed: SSE backpressure and last-event-id continuity for long-running agent tools **Date of Record:** 2026 November 13 **Scope:** Applies to MCP servers and clients using the HTTP-native streamable transport with session resumability (Last-Event-ID replay) over Server-Sent Events, where tool invocations can run longer than a single HTTP request lifetime. Does not apply to stdio transports, to stateless HTTP transports without `Mcp-Session-Id`, or to deployments where the server never emits SSE `id:` fields. ## Core Distinction The distinction that matters operationally is between *backpressure-induced silence* and *session discontinuity*. Under SSE, a server that is slow to produce events, or whose TCP send buffer is full because the client is reading slowly, produces no bytes on the wire. A client that has not received an event within its idle timeout cannot, from that observation alone, distinguish "the tool is still running and the server is applying backpressure" from "the server process was recycled, the session store was lost, and my `Last-Event-ID` will never be replayed." Both present identically as a stalled stream. The invariant that resolves the ambiguity is this: **a resumable MCP session is only valid if the server can prove, at reconnect time, that it will replay every event strictly after the client's `Last-Event-ID` or explicitly signal termination.** If the server cannot make that proof — because the event buffer was evicted, because the session was reaped, or because the replay window does not cover the client's cursor — then resumption has *failed*, and the client must not treat the silence as backpressure and wait. It must escalate to a new session or surface a tool error. The non-obvious part: a 200 OK on the reconnect request with a fresh SSE stream is *not* proof of resumption. Many servers accept the reconnect, open a new stream, and begin emitting events from the current head — silently skipping the gap between the client's cursor and the head. The client sees a healthy stream and loses state. ## What Was Observed In a deployment running a code-analysis agent tool with 4–11 minute execution times, the following sequence recurred: 1. The tool call opened an SSE stream; the server emitted a `ping`/keepalive every 15s and an `id:` on each real event. 2. At minute ~7, the client's idle timer (set to 90s, comfortably above the 15s keepalive) fired because the server's keepalive loop had been blocked behind a synchronous write to a full socket buffer — the client's event loop was itself saturated by a concurrent large-context model call, so it was not draining the socket. The server's keepalive goroutine blocked on `write()`. 3. The client reconnected with `Last-Event-ID: 41`. The server had, in the interim, evicted events older than a 64-event ring buffer and had advanced to event 130. It returned 200 OK and streamed from event 130. 4. The client, seeing a 200 and a live stream, assumed resumption succeeded. Events 42–129 — including the tool's intermediate `progress` notifications and the terminal `result` for one sub-task — were never delivered. The agent silently proceeded with a partial result set. The failure was not the backpressure and not the reconnect. It was the acceptance of a reconnect that could not honor the cursor. The server had no way to signal "I cannot replay from 41"; it simply started from the head. A second variant: after a server restart, the session store was in-memory only. Reconnect with a valid `Last-Event-ID` returned 404, which the client correctly treated as session loss. But a *third* variant returned 200 with an empty stream and no events — a fresh session created implicitly under the same `Mcp-Session-Id`. That silent re-creation is the most dangerous case, because it looks like a healthy idle stream. ## Condition of Validity Resumption is valid only when all of the following hold: - The server retains a monotonic, gap-free event log per session, and the client's `Last-Event-ID` is within the retained window at reconnect time. - The server, on receiving `Last-Event-ID`, either (a) replays every event with a higher id before emitting any new event, or (b) rejects the request with a distinguishable status/error indicating the cursor is unreplayable. - The client's idle timeout is strictly greater than the server's keepalive interval plus the maximum expected single-write stall, and the client's socket-read path is not itself blocked by application work. - Session identity is durable across server restarts, or restart causes an explicit session-invalid response rather than implicit re-creation. If the event log is a bounded ring buffer, validity additionally requires that the buffer depth, multiplied by the minimum inter-event interval, exceeds the maximum plausible disconnect duration. A 64-event buffer with 15s keepalives covers ~16 minutes of pure silence but far less if events are bursty. ## Decision Boundary The client must classify a stalled stream into exactly one of three states, and the boundary between them is the *replay proof*, not the passage of time: - **Delayed (wait):** stream is open, keepalives are arriving, and the cursor is provably within the server's retained window. Silence here is backpressure; keep waiting. - **Resumption failed (escalate):** reconnect returned success but the server cannot prove replay from the cursor — cursor below the retained floor, session reaped, or an implicit new session. Do not wait. Re-issue the tool call under a new session or surface a partial-result error. - **Session lost (re-init):** explicit 404/410 or a session-invalid error. Re-initialize. The boundary is crossed the moment the client cannot establish that the next event it receives will be the immediate successor of its cursor. A 200 OK does not establish this. Only an explicit replay acknowledgment — a server-emitted event whose id equals cursor+1, or a header/field stating the replay floor — does. Absent that acknowledgment, a reconnect that begins at an id greater than cursor+1 is a *failure*, not a resumption, and must be treated as such even though the HTTP layer reported success. ## Operational Effect Treating a cursor-skipping reconnect as success produces silent partial results — the worst failure class for agent tools, because the agent cannot detect that its inputs were truncated and will reason confidently over incomplete data. The operational remedy is to make replay-floor explicit and to make the client refuse streams that cannot prove continuity. In practice this means: servers must emit a machine-readable replay-floor (or reject unreplayable cursors with a distinct status), clients must verify the first post-reconnect event id against the cursor, and idle timeouts must be decoupled from application-thread saturation so that backpressure is never mistaken for loss. The following reference implementation encodes the decision boundary as an explicit exception raised at the point where continuity cannot be proven. ```python from __future__ import annotations import dataclasses from typing import Iterable, Optional class StreamResumptionError(RuntimeError): """Raised when a reconnect cannot prove replay continuity from the client cursor.""" @dataclasses.dataclass(frozen=True) class Event: id: int kind: str payload: dict @dataclasses.dataclass(frozen=True) class ReconnectResult: http_status: int replay_floor: Optional[int] # lowest id the server can still replay, if declared first_event: Optional[Event] session_durable: bool def verify_resumption( cursor: int, result: ReconnectResult, retained_floor: Optional[int] = None, ) -> Event: """ Decide whether a reconnect is a valid resumption from `cursor`. Raises StreamResumptionError when the server cannot prove that the next event it delivers is the immediate successor of the client's cursor. """ if result.http_status in (404, 410): raise StreamResumptionError( f"session lost: status={result.http_status}; re-initialize required" ) if result.http_status != 200: raise StreamResumptionError( f"unexpected reconnect status={result.http_status}" ) floor = result.replay_floor if result.replay_floor is not None else retained_floor if floor is not None and cursor < floor - 1: # Cursor is older than the retained window: the gap is unreplayable. raise StreamResumptionError( f"cursor {cursor} below replay floor {floor}; " f"events {cursor + 1}..{floor - 1} are lost" ) if not result.session_durable: # Implicit re-creation under the same session id looks like a live stream # but has no continuity guarantee. raise StreamResumptionError( "session not durable across reconnect; implicit re-creation " "cannot honor Last-Event-ID" ) ev = result.first_event if ev is None: raise StreamResumptionError( "reconnect returned an empty stream; cannot prove continuity" ) if ev.id != cursor + 1: raise StreamResumptionError( f"replay gap: expected event id {cursor + 1}, got {ev.id}; " f"silent skip of {ev.id - cursor - 1} event(s)" ) return ev def drain_with_continuity( cursor: int, events: Iterable[Event], result: ReconnectResult, ) -> list[Event]: """Validate resumption, then drain the remainder, asserting gap-free ids.""" first = verify_resumption(cursor, result) out = [first] expected = first.id + 1 for ev in events: if ev.id != expected: raise StreamResumptionError( f"non-contiguous stream: expected {expected}, got {ev.id}" ) out.append(ev) expected += 1 return out ```