# Where must an instance persist the Last-Event-ID cursor: Session Resumption vs. Event Replay in MCP HTTP-Native Streaming **Date of Record:** 2026 November 13 **Scope:** Applies to MCP servers exposing the HTTP-native (Streamable HTTP) transport with SSE-based server-to-client streaming and `Mcp-Session-Id` resumption. Does not apply to stdio transport, nor to HTTP servers that terminate streams on every request without supporting `Last-Event-ID` replay. ## Core Distinction The MCP Streamable HTTP transport specifies two independent, weakly coupled pieces of state per session: the **session identity** (`Mcp-Session-Id`, a server-issued opaque token) and the **event stream cursor** (`Last-Event-ID`, an SSE-level resumption token). The common and incorrect mental model treats them as one thing — that a valid session ID implies the ability to resume at any prior event. They are orthogonal. A session ID answers "does this logical conversation still exist on this instance?" The Last-Event-ID answers "which events in the per-stream buffer have already been delivered?" The failure boundary is this: **the cursor is only meaningful relative to the specific stream buffer that produced it, and that buffer may live on a different instance than the one holding the session state.** In a horizontally scaled deployment, session state is frequently replicated or sticky-routed, while the per-stream replay buffer is almost always node-local and ephemeral. A client that reconnects with a valid `Mcp-Session-Id` and a stale `Last-Event-ID`, routed to a different replica, receives a `200 OK` and an empty or restarted stream — silently. There is no protocol signal for "I have your session but not your events." ## What Was Observed During a rolling restart of an MCP server deployment (three replicas behind a round-robin load balancer, no session affinity), a client agent maintained a long-lived SSE stream for tool-call progress notifications. The client persisted `Mcp-Session-Id` and the most recent SSE `id:` field, per spec. On reconnect after a replica was drained, the following occurred: 1. The reconnect request carried a valid `Mcp-Session-Id` (session state was stored in a shared Redis, so any replica accepted it) and a `Last-Event-ID` of `evt-4471`. 2. The request was routed to a replica that had never served this stream. Its local replay buffer was empty. 3. The server, correctly per its own logic, could not find event `evt-4471`. It did **not** return `404`/`410` for the session (the session existed), and it did **not** error on the cursor (the spec permits servers to ignore `Last-Event-ID`). It opened a fresh stream starting at the next event the *application* produced. 4. Events `evt-4472` through `evt-4503` — 32 tool-progress notifications, including two terminal `completed` events — were never delivered. The client blocked waiting for a completion that had already been emitted on the dead replica. The critical observation: **no layer reported an error.** Session lookup succeeded, HTTP status was `200`, the SSE stream was well-formed. The gap was purely semantic and only detectable by the client noticing a non-contiguous event ID sequence — which the spec does not require clients to track, and which most SDKs do not. A second, subtler variant was observed with a single replica. When the SSE replay buffer was bounded (last 256 events) and the client reconnected after a long disconnection, the requested `Last-Event-ID` had been evicted. The server restarted the stream from the current head. Again: `200 OK`, no gap signal. The buffer bound is an implementation detail invisible to the client, so the client cannot distinguish "resumed exactly" from "resumed at head, events lost." ## Condition of Validity The "session ID implies resumability" assumption holds only when all of the following are true simultaneously: - Session state and stream replay buffer are **co-located** on the same instance, or the replay buffer is itself replicated with the session. - The client is **pinned** to the instance holding the buffer for the lifetime of the resumption window (sticky sessions with the pin surviving restarts, or single-replica deployment). - The requested `Last-Event-ID` is **within the retained buffer window** — i.e., the disconnection duration is shorter than the buffer's retention, and the buffer is not bounded below the expected gap size. - The server **honors** `Last-Event-ID` rather than treating it as advisory (the spec makes honoring optional; many implementations ignore it). Break any one and the invariant fails. In practice, horizontal scaling breaks the first two, and bounded memory breaks the third. The default configuration of most HTTP-native MCP deployments breaks at least one. ## Decision Boundary The operational decision is **who owns gap detection and replay**, and it must be made explicitly rather than inherited from the transport: - **If the server is single-instance and the buffer is unbounded (or bounded above the maximum tolerable gap):** the server can honor `Last-Event-ID` faithfully and the client may trust contiguity. This is the only configuration where "resume from cursor" is a real guarantee. - **If the server is multi-instance or the buffer is bounded:** the transport cannot guarantee gap-free resumption. The server MUST either (a) reject resumption it cannot satisfy with an explicit error rather than silently restarting at head, or (b) the client MUST treat every reconnect as a potential gap and reconcile application state out-of-band (re-query tool status by ID, not by waiting for a notification). - **The forbidden middle:** silently accepting a `Last-Event-ID` the server cannot replay and opening a fresh stream at head. This produces the worst outcome — a client that believes it resumed and blocks forever. The rule distilled: **a server that cannot replay a cursor must not pretend to.** It should signal the boundary, because the client cannot infer it from a healthy stream. ## Operational Effect The practical consequence is that MCP clients must not treat SSE event delivery as reliable exactly-once or even at-least-once across reconnects unless the deployment contract explicitly guarantees co-located, unbounded replay. The safe pattern is **notification as hint, state as truth**: every terminal event (tool completion, error) must be recoverable by an explicit query keyed on the operation ID, not solely by having received the SSE event. The SSE stream becomes an optimization for latency, not a correctness channel. For servers, the effect is a design obligation: when `Last-Event-ID` cannot be satisfied, respond with a distinguishable signal (e.g., a `410 Gone`-style resumption rejection or an SSE comment marking a discontinuity) so the client can trigger reconciliation. Silent head-restart is the failure mode that turns a bounded, recoverable gap into an unbounded client hang. ```python class ResumptionGapError(RuntimeError): """Raised when a resumption request cannot be satisfied without an event gap.""" pass def resume_stream(session_id: str, last_event_id: str, session_store, replay_buffer, buffer_head_id: str) -> list: # Session identity and event cursor are orthogonal; check both independently. if not session_store.exists(session_id): raise ResumptionGapError( f"session {session_id!r} unknown; cannot resume, client must re-init" ) # The replay buffer is node-local. If this instance never served the stream, # it cannot replay the cursor even though the session exists. if last_event_id not in replay_buffer: raise ResumptionGapError( f"session {session_id!r} exists but Last-Event-ID {last_event_id!r} " f"is not in this instance's replay buffer (head={buffer_head_id!r}); " f"refusing to silently restart at head — signal gap to client" ) # Only now is a contiguous replay actually guaranteed. return replay_buffer.events_after(last_event_id) def handle_reconnect(session_id: str, last_event_id: str, session_store, replay_buffer, buffer_head_id: str, emit_discontinuity_signal) -> list: try: return resume_stream(session_id, last_event_id, session_store, replay_buffer, buffer_head_id) except ResumptionGapError as exc: # Do not open a fresh stream at head and call it success. # Emit an explicit discontinuity so the client reconciles out-of-band. emit_discontinuity_signal(session_id, reason=str(exc)) raise ```