# Why does an instance of an MCP HTTP-native transport session lose resumability when the SSE event ID sequence is re-based across a reconnect? **Date of Record:** 2026 November 11 **Scope:** Applies to MCP servers exposing the HTTP-native (Streamable HTTP) transport with server-sent events and `Last-Event-ID` resumption, where the server maintains a per-session event log or replay buffer. Does not apply to stdio transports, nor to HTTP transports that do not advertise an event-ID-based resume capability. ## Core Distinction The distinction that matters is between **session identity** and **stream identity**. An MCP session (identified by `Mcp-Session-Id`) is long-lived and survives client reconnects. An SSE stream is ephemeral: it terminates on network drop, proxy timeout, or client-side `EventSource` teardown. Resumability is a property of the *stream*, negotiated per reconnect via `Last-Event-ID`, and it is only meaningful if the server's event IDs form a monotonic, gap-detectable sequence that is *stable across stream lifetimes*. The non-obvious failure is that many implementations treat event IDs as stream-local counters (reset to 0 or 1 on each new SSE connection) while treating the replay buffer as session-scoped. The two scopes diverge. A client that reconnects and sends `Last-Event-ID: 47` is then asked to resume against a sequence that has restarted at 1, and the server either silently replays from the beginning (duplicate delivery), silently drops the resume hint and continues live (gap), or rejects the resume as out-of-range (hard failure). None of these raise a transport-level error; all three corrupt the JSON-RPC message stream at the application layer. ## What Was Observed During a long-running tool-invocation session behind an L7 load balancer with a 60-second idle timeout, the client's SSE connection was severed mid-stream while the server was emitting `notifications/progress` for a multi-minute operation. On reconnect the client sent `Last-Event-ID` equal to the last progress notification it had fully processed. The server's replay logic compared that ID against its buffer, which had been re-initialized on the new stream with IDs starting at `1`. Because the resume ID was numerically larger than any current buffer entry, the server interpreted it as "already caught up," emitted no replay, and resumed live emission from `1`. Observed consequences, in order of subtlety: 1. **Silent gap.** Progress notifications between the disconnect and the resume were never delivered. The client's progress bar jumped backward because the next live event carried a *lower* ID than the last one processed. Clients that assert monotonicity on event IDs (a reasonable invariant) aborted the stream. 2. **Duplicate side effects on replay-capable paths.** In a second instance where the server did honor the out-of-range ID by replaying the full buffer, the client re-processed already-handled `notifications/message` frames, producing duplicate log lines and, in one case, a duplicate tool call because the message was a request rather than a notification. 3. **Session desynchronization without error.** The `Mcp-Session-Id` remained valid throughout. `initialize` was never re-run. The server considered the session healthy; the client considered the stream healthy; the message stream was nonetheless inconsistent with both. The diagnostic signature is that *nothing throws*. There is no 4xx, no `error` JSON-RPC frame, no connection reset. The failure is only visible by cross-checking the expected event-ID sequence against the received one. ## Condition of Validity Resumption via `Last-Event-ID` is valid only when all of the following hold: - Event IDs are **session-scoped and monotonic**, not stream-scoped. The generator must not reset across reconnects within a session. - The replay buffer is keyed by the same ID space and retains events for at least the maximum expected reconnect window (typically bounded by the load balancer idle timeout plus client backoff). - The server distinguishes three resume outcomes explicitly: `resumed` (replay from ID+1), `gap` (ID older than buffer floor — must signal loss, not silently continue), and `future` (ID ahead of head — must be treated as protocol violation, not as caught-up). - The client treats event-ID monotonicity as an invariant and tears down the stream on violation rather than silently accepting reordered IDs. The condition does **not** hold when the transport is used in a stateless mode where each POST carries a complete request and no server-side session buffer exists; there, `Last-Event-ID` has no meaning and reconnection is semantically a fresh request. ## Decision Boundary The decision boundary is where an implementation chooses between two defensible designs: - **Design A — session-scoped ID space.** IDs are drawn from a per-session monotonic counter persisted with the replay buffer. Reconnect with `Last-Event-ID` is always resolvable, and the three outcomes above are distinguishable. Cost: the buffer must be retained and bounded, and eviction policy must be explicit. - **Design B — stream-scoped ID space with no resume promise.** IDs reset per stream and the server does not advertise resumability. Reconnect is a fresh stream; the client must re-derive state from session-level `initialize`/`tools/list` or from application-level checkpointing. Cost: no gap-free delivery; the client owns all reconciliation. The failure mode documented here arises from an incoherent hybrid: stream-scoped IDs *combined with* an advertised resume capability and a session-scoped buffer. The boundary rule is therefore: **either the ID space matches the buffer's scope, or the resume capability must not be advertised.** There is no correct middle configuration. ## Operational Effect For an agent operating against such a server, the practical rule is: never trust `Last-Event-ID` acceptance as evidence of gap-free delivery. On every reconnect, validate that the first received event ID equals `last_processed_id + 1`; if it is greater, a gap occurred and application-level state must be re-synchronized; if it is less, the server re-based its sequence and the stream must be treated as a new stream requiring full reconciliation. Treat a resume response that carries no replay and no explicit gap signal as a protocol violation. When building the server side, persist the ID counter with the buffer and implement the three-outcome resume explicitly. ```python class ResumeSequenceError(RuntimeError): """Raised when an SSE resume violates the session-scoped event-ID invariant.""" pass def validate_resume(last_processed_id: int, first_received_id: int, buffer_floor: int) -> str: """ Classify a resume outcome for an MCP HTTP-native SSE stream. Returns one of: 'resumed', 'gap', 'rebase'. Raises ResumeSequenceError on a future ID (protocol violation) or on an unresolvable state that the caller must not silently accept. """ if first_received_id == last_processed_id + 1: return "resumed" if first_received_id > last_processed_id + 1: # Events were dropped between disconnect and resume. if last_processed_id < buffer_floor: raise ResumeSequenceError( f"resume id {last_processed_id} predates buffer floor {buffer_floor}; " "gap is unrecoverable from server buffer" ) return "gap" if first_received_id <= last_processed_id: # Server re-based its ID sequence across the reconnect. return "rebase" raise ResumeSequenceError( f"unreachable: last={last_processed_id} first={first_received_id}" ) def on_reconnect(last_processed_id: int, first_received_id: int, buffer_floor: int) -> None: outcome = validate_resume(last_processed_id, first_received_id, buffer_floor) if outcome == "rebase": # Do not continue as if the stream were healthy. raise ResumeSequenceError( f"server re-based event IDs (last={last_processed_id}, " f"first={first_received_id}); stream must be treated as new" ) if outcome == "gap": # Caller must re-synchronize application state before consuming further. raise ResumeSequenceError( f"unrecoverable gap after id {last_processed_id}; " "re-initialize session state before resuming consumption" ) ```