# In what scenarios must an instance treat MCP HTTP-native transport session resumption as unsafe after a mid-stream `Last-Event-ID` gap **Date of Record:** 2026 November 14 **Scope:** Applies to MCP servers and clients using the HTTP-native transport (Streamable HTTP with `text/event-stream` responses, `Mcp-Session-Id` headers, and `Last-Event-ID`-based resumption). Does not apply to stdio transport, nor to stateless HTTP request/response mode where no session resumption is negotiated. ## Core Distinction MCP HTTP-native transport defines two distinct recovery primitives that are routinely conflated: **session identity** (`Mcp-Session-Id`) and **event continuity** (`Last-Event-ID`). Session identity answers "which logical conversation does this belong to?" Event continuity answers "which messages has this stream already delivered?" A server can legitimately accept a session ID while being unable to honor the `Last-Event-ID` the client presents. The non-obvious boundary is this: **a successful session resumption is not evidence of a gapless event history.** When a client reconnects with a valid `Mcp-Session-Id` but a `Last-Event-ID` that the server cannot place in its retained buffer, the server is permitted to start a fresh event stream. The client sees HTTP 200 and a live stream, concludes resumption succeeded, and silently loses every message emitted during the disconnect window. This is the failure mode: transport-level success masking application-level message loss. ## What Was Observed During a routine reconnect after a load-balancer idle timeout (observed at ~60s), a client presented its `Mcp-Session-Id` and the last event ID it had processed (`evt-000418`). The server, whose per-session event ring buffer had evicted IDs older than `evt-000512` due to high notification volume, returned HTTP 200 with a new stream beginning at `evt-000513`. No error, no warning header, no `Mcp-Session-Id` change. The client resumed normally and processed `evt-000513` onward. Events `evt-000419` through `evt-000512` — including two `tools/list_changed` notifications and one progress update that gated a downstream step — were never delivered. The client's subsequent call failed with a tool-not-found error that appeared, from its perspective, to be a server bug. The invariant violated: **the server's resumption acceptance does not imply `Last-Event-ID` was honored.** The protocol allows the server to resume the session while discarding the requested replay point. Only a server that maintains an unbounded or sufficiently large replay buffer can honor arbitrary `Last-Event-ID` values; most implementations bound it. ## Condition of Validity The safe assumption holds only when **all** of the following are true: 1. The server advertises and enforces a replay buffer whose retention window exceeds the maximum expected disconnect duration, and 2. The server signals a replay gap explicitly — e.g., by returning a new stream whose first event ID is not contiguous with the presented `Last-Event-ID`, or by rejecting with a defined status — and 3. The client validates contiguity: it checks that the first event ID received after reconnect is either the expected successor or an explicit replay marker. If any of these fail, resumption must be treated as **potentially lossy**, and the client must re-derive state rather than trust incremental delivery. In practice, condition 2 is the one that fails most often, because the protocol does not mandate a gap signal and implementations do not emit one. ## Decision Boundary The decision rule for an agent operating over MCP HTTP-native transport: - **Resume incrementally** (trust `Last-Event-ID`) only if the client can prove the server honored the replay point — i.e., the first post-reconnect event ID is the exact successor of the last processed ID, or the server returned an explicit replay-from marker. - **Resume with full state re-derivation** (do not trust incremental delivery) if the session ID is valid but the event ID continuity cannot be proven. This means re-issuing `initialize`-adjacent state queries (`tools/list`, `resources/list`, current progress) rather than assuming the stream is complete. - **Abandon the session** if the `Mcp-Session-Id` itself is rejected (e.g., HTTP 404 on the session), which indicates server-side session termination and mandates a new session. The boundary is crossed the moment a client treats "HTTP 200 + valid session ID" as "no messages lost." That inference is invalid. ## Operational Effect Clients that conflate session validity with event continuity exhibit a characteristic failure signature: intermittent, hard-to-reproduce errors that correlate with network blips and high notification volume, always manifesting as "the server forgot something it told me before." The errors are not reproducible under stable networking because the replay buffer never evicts. Mitigation is client-side validation of event-ID contiguity plus a re-derivation path on discontinuity; server-side, the only robust fix is an explicit gap signal or a replay buffer sized against the documented maximum reconnect window. ```python class McpResumptionGapError(RuntimeError): """Raised when an MCP HTTP-native stream resumes a session but cannot prove event-ID continuity, so incremental delivery is untrustworthy.""" pass def validate_resumption( session_id: str | None, presented_last_event_id: str | None, first_event_id_after_reconnect: str | None, server_replay_marker: str | None = None, ) -> None: """Validate that a reconnected MCP stream honored the requested replay point. Raises McpResumptionGapError if the session is valid but event continuity cannot be proven; caller must fall back to full state re-derivation. """ if session_id is None: raise McpResumptionGapError("no Mcp-Session-Id; cannot resume, start new session") if presented_last_event_id is None: # Fresh stream, nothing to replay; continuity is vacuously fine. return if server_replay_marker is not None: # Server explicitly signaled where replay begins; trust it. return if first_event_id_after_reconnect is None: raise McpResumptionGapError( f"session {session_id}: stream opened but no event ID observed; " "cannot verify continuity" ) expected_successor = _increment_event_id(presented_last_event_id) if first_event_id_after_reconnect != expected_successor: raise McpResumptionGapError( f"session {session_id}: presented Last-Event-ID {presented_last_event_id}, " f"expected successor {expected_successor}, got {first_event_id_after_reconnect}; " "replay buffer likely evicted the gap — re-derive state" ) def _increment_event_id(event_id: str) -> str: # Event IDs are opaque strings; only the numeric-suffix convention is # validated here. Non-conforming IDs must be treated as unverifiable. prefix, _, suffix = event_id.rpartition("-") if not suffix.isdigit(): raise McpResumptionGapError( f"event ID {event_id!r} not in verifiable numeric-suffix form" ) return f"{prefix}-{int(suffix) + 1:06d}" ```