# When must an instance treat an MCP SSE session resumption as a new logical stream rather than a continuation: the `Last-Event-ID` epoch-collision boundary **Date of Record:** 2026 November 8 **Scope:** Applies to HTTP-native MCP transports that multiplex multiple logical server-to-client streams over a single `text/event-stream` response and support resumption via `Last-Event-ID`. Does not apply to stdio transports, nor to single-stream SSE endpoints where event IDs are globally monotonic and never reused across server restarts. ## Core Distinction The distinction that matters is between **transport-level resumption** and **logical-session continuity**. A client that reconnects with `Last-Event-ID: 4127` is asking the server to resume the *transport* byte stream from a cursor. It is not, by itself, asserting that the logical MCP session (initialization state, capability negotiation, in-flight tool calls, subscription set) is the same one that produced event 4127. Most implementations conflate these. They store a per-session event counter, persist recent events in a ring buffer keyed by that counter, and on reconnect replay from `Last-Event-ID + 1`. This works until the counter is reused — which happens on every server restart, every horizontal scale-out to a fresh replica, and every session-migration event. At that moment, event ID `4127` from epoch A and event ID `4127` from epoch B are both "valid," and the server has no way to tell which one the client means. If the server naively resumes, it will replay epoch-B events to a client whose logical state is epoch-A, or worse, silently deliver a gap because the ring buffer no longer holds epoch-A's history. The correct invariant: **an event ID is only meaningful within the epoch that issued it.** Resumption across an epoch boundary must be rejected and downgraded to a fresh logical stream, not silently continued. ## What Was Observed During a rolling deploy of an MCP server behind a load balancer, a subset of clients (roughly 3% of long-lived sessions) entered a state where they received tool-call results for requests they had never issued. The pattern was consistent: 1. Client establishes SSE stream, receives events with IDs `1..N` from replica A (epoch `e1`). 2. Replica A is drained; client's TCP connection drops; client reconnects with `Last-Event-ID: N`. 3. Load balancer routes to replica B, freshly started, epoch `e2`, whose event counter also begins at `1`. 4. Replica B, holding a ring buffer of its own events `1..M` (where `M < N` because it started later), receives `Last-Event-ID: N`. Its lookup misses — `N` is beyond its buffer. 5. Depending on implementation, replica B either (a) returns HTTP 400 and the client retries without `Last-Event-ID` (correct but lossy), or (b) treats the miss as "resume from current head" and begins streaming new events, or (c) — the failure case — clamps `N` to its buffer size and replays events `1..M` as if they were the continuation. Case (c) is the silent corruption. The client's MCP session layer sees event IDs it has already processed (or never processed) and, because the IDs are numerically plausible and monotonic from the client's perspective after clamping, accepts them. Multiplexed logical streams are re-bound to the wrong `stream_id` because the server's stream-registration table is also per-epoch and the client's `stream_id` namespace does not survive the epoch change. The second observed failure is subtler: when the server *does* reject with 400 and the client retries without `Last-Event-ID`, the client's MCP session is now receiving a *new* stream on the same logical session. Any in-flight server-to-client requests (sampling, elicitation, progress notifications) that were pending at disconnect are orphaned. The client waits indefinitely for responses that will never arrive because the server-side handler for those requests died with replica A. ## Condition of Validity The epoch-collision boundary is only a live hazard under the conjunction of: - **Multiplexed SSE**: more than one logical stream shares the HTTP response, so event IDs are not globally unique identifiers but per-stream sequence numbers scoped to a session. - **Resumable transport**: `Last-Event-ID` is honored, meaning the client can present a cursor from a prior connection. - **Non-durable or non-global event ID space**: IDs are generated per-process (counter, timestamp-within-process, or UUIDv4 truncated to a sortable prefix) rather than from a durable, globally ordered log (e.g., a single-writer sequencer or a replicated log with monotonic offsets). - **Topology change during session lifetime**: replicas restart, scale, or migrate while clients hold open streams. If any one is absent, the boundary does not bite. A single-replica server that never restarts during a session lifetime is safe. A server using a durable global event log (Kafka offset, Postgres sequence with `pg_current_wal_lsn`) is safe because IDs are never reused. A server that does not honor `Last-Event-ID` at all is safe-by-degradation (every reconnect is a fresh stream) but loses resumability. ## Decision Boundary The decision the server must make on receiving `Last-Event-ID: X` is not "do I have event X?" but "**do I have event X *and* am I the same epoch that issued it?**" Encode the epoch into the event ID itself, or carry it in a parallel header, so the check is a single comparison rather than a buffer lookup that can spuriously succeed. Concretely: event IDs should be opaque strings of the form `:`, where `` is a value that changes on every process start (a boot UUID, a monotonic replica-generation counter from the orchestrator, or a hash of the durable-log high-water mark at startup). On reconnect: - If `epoch(Last-Event-ID) == current_epoch`: resume from `seq + 1` within the ring buffer. If `seq + 1` is no longer in the buffer, this is a **gap** — reject with a resumption-failed signal, do not clamp. - If `epoch(Last-Event-ID) != current_epoch`: the client's cursor is from a foreign epoch. Do **not** attempt to map it. Reject resumption, instruct the client to re-initialize the logical session, and — critically — the client must treat all previously pending server-to-client requests as failed, not merely delayed. The boundary is sharp: there is no correct "best effort" resume across epochs. Any attempt to continue produces either duplicate delivery (benign but confusing) or gap delivery (silent corruption of multiplexed stream state). ## Operational Effect After adopting epoch-tagged event IDs and treating cross-epoch resumption as a hard failure requiring logical-session re-initialization, the rolling-deploy corruption class disappeared. The cost is that clients must implement session re-establishment on every resumption failure, which means the MCP session layer needs a distinct `ResumptionRejected` path separate from `TransportError`. In practice this is the correct place to put the logic: transport errors are retryable with backoff; resumption rejections are not retryable without re-initialization, because retrying with the same stale cursor will fail identically. The secondary effect is that pending server-to-client requests must be explicitly failed on resumption rejection. A client that re-initializes but leaves orphaned request promises pending will hang the session's request tracker until a timeout, which in multiplexed setups can block the entire stream if the tracker is serialized. The re-initialization path must drain and reject all outstanding server-to-client request handlers before establishing the new stream. A third effect worth recording: the ring buffer must be sized not by "how much history do we want to replay" but by "how long can a client be disconnected and still resume within the same epoch." Under a 30-second reconnect window and 100 events/sec, that is 3000 events minimum; under a 5-minute window it is 30000. Undersizing the buffer converts same-epoch resumptions into rejections, which is safe but degrades the resumability guarantee the transport was supposed to provide. ```python from __future__ import annotations import uuid from dataclasses import dataclass class ResumptionEpochMismatch(RuntimeError): """Raised when a Last-Event-ID cursor belongs to a foreign server epoch.""" class ResumptionGap(RuntimeError): """Raised when the cursor is same-epoch but the requested seq is no longer buffered.""" @dataclass(frozen=True) class EventId: epoch: str seq: int def encode(self) -> str: return f"{self.epoch}:{self.seq}" @classmethod def decode(cls, raw: str) -> "EventId": epoch, _, seq = raw.rpartition(":") if not epoch or not seq.isdigit(): raise ValueError(f"malformed event id: {raw!r}") return cls(epoch=epoch, seq=int(seq)) class SseSession: def __init__(self) -> None: # A fresh epoch per process start. Never reused, never derived from seq. self.epoch = uuid.uuid4().hex self._buffer: dict[int, str] = {} self._next_seq = 1 def emit(self, payload: str) -> EventId: eid = EventId(epoch=self.epoch, seq=self._next_seq) self._buffer[eid.seq] = payload self._next_seq += 1 return eid def resume(self, last_event_id: str) -> list[tuple[EventId, str]]: cursor = EventId.decode(last_event_id) if cursor.epoch != self.epoch: # Cross-epoch: the cursor is from a foreign incarnation. # Do NOT clamp, replay, or otherwise "best effort" continue. raise ResumptionEpochMismatch( f"cursor epoch {cursor.epoch!r} != server epoch {self.epoch!r}; " "client must re-initialize the logical session" ) start = cursor.seq + 1 if start < self._next_seq and start not in self._buffer: # Same epoch but the ring buffer has evicted the continuation. raise ResumptionGap( f"event {start} evicted; buffer holds {min(self._buffer, default='-')}" f"..{max(self._buffer, default='-')}" ) return [ (EventId(self.epoch, s), self._buffer[s]) for s in range(start, self._next_seq) if s in self._buffer ] ```