# Under what conditions must an instance treat an MCP `Last-Event-ID` resumption as a *new* stream rather than a continuation of the prior SSE session **Date of Record:** 2026 November 10 **Scope:** Applies to MCP servers and clients that migrated from the legacy HTTP+SSE transport (dual-endpoint `/sse` + `/messages`) to the Streamable HTTP transport (single endpoint, optional SSE upgrade per response, `Last-Event-ID` based resumption), specifically when a client presents a `Last-Event-ID` header on a `GET` or `POST` after a reconnect. It does not apply to pure in-process transports (stdio), nor to the legacy SSE transport's own reconnect semantics, which lack a resumable event cursor by design. ## Core Distinction The critical distinction is between **event-log continuity** and **session continuity**. The Streamable HTTP transport spec allows a client to resume a broken stream by sending `Last-Event-ID`; the server is expected to replay events *after* that ID. Operators migrating from the SSE transport routinely conflate "the client reconnected with the same session ID and a Last-Event-ID" with "the server still owns a live, replayable event buffer for that stream." These are independent facts. The session may be alive while the event log has been trimmed, forked, or never existed on this process. A resumption request is only a *continuation* if the server can prove it still holds the authoritative event log segment beginning immediately after the supplied ID, in the same stream incarnation. Otherwise it is a *new stream* that happens to carry a stale cursor, and must be treated as such — including re-establishing any server-side state the client assumes is still bound to the old stream. ## What Was Observed In a multi-replica deployment behind a round-robin load balancer, a client opened a Streamable HTTP `GET` to establish the SSE stream, received events `e1..e7`, and the connection dropped. The client reconnected with `Last-Event-ID: e7` and the same `Mcp-Session-Id`. The request landed on a different replica. That replica had a valid session record (sessions were stored in Redis) but its local event ring buffer for the stream was empty — the buffer was process-local and had never been replicated. The server, following a naive "session exists ⇒ resume" check, accepted the `Last-Event-ID` and began emitting *new* events starting at `e8` from its own fresh counter. The client had already received `e8` and `e9` from the first replica before the drop but had not acknowledged them; it now saw duplicate IDs with different payloads and, worse, a gap in causal ordering because the second replica's `e8` was a different logical event that reused the counter. Downstream tool-call orchestration replayed a side-effecting `tools/call` twice. A second, subtler case: after migration from the legacy SSE transport, a client library continued sending `Last-Event-ID` on the initial `POST` of a new turn, because the legacy client had persisted the last SSE event ID across *sessions* (legacy SSE had no session-scoped cursor). The new server interpreted a cursor from a prior, unrelated session as a resumption request for the current stream and attempted to replay from an ID it had never issued, silently dropping the request body's intent. ## Condition of Validity A `Last-Event-ID` resumption is a valid continuation **iff all of the following hold simultaneously**: 1. The `Mcp-Session-Id` resolves to a session that is still in `active` state (not expired, not terminated, not resumed-into-a-new-incarnation). 2. The server can locate the **authoritative** event log segment for the specific stream that issued the referenced ID, and that segment is contiguous from `Last-Event-ID + 1` to the current head. "Authoritative" means the segment is owned by the same logical stream incarnation, not merely a buffer with a matching numeric range. 3. The event ID is **globally unique within the session**, not merely unique within a process-local counter. If IDs are per-process counters, condition 2 cannot be evaluated across replicas and resumption must be refused or redirected to the owning replica. 4. The stream has not been **forked or superseded** — i.e., no newer stream for the same session has been established that would make the old stream's tail ambiguous. If any condition fails, the request is a new stream. The server must not silently emit events from a fresh counter under the old cursor; it must either (a) reject with a resumable error that instructs the client to re-establish without `Last-Event-ID`, or (b) accept as a new stream and explicitly signal that the cursor was not honored (e.g., a control event indicating `resumed: false`). ## Decision Boundary The boundary is **not** "does the session exist" and **not** "does the ID parse." It is "can this process, right now, produce the exact next event in the client's causal sequence." Concretely, the decision procedure is: - Resolve session. If absent/expired → new stream, reject cursor. - Resolve stream ownership for the referenced ID. If the ID's origin replica ≠ this replica and the event log is not shared/replicated → **do not** attempt local replay; either proxy to the owner or reject with a redirect/new-stream signal. - Check contiguity: the segment from `id+1` to head must be fully present. A trimmed head (ring buffer eviction) means the events between the client's cursor and the current head are lost; replaying only the surviving tail creates a silent gap. Treat as new stream. - Check incarnation: if the stream was re-established (new `GET` without cursor, or a fork) after the referenced ID was issued, the cursor belongs to a dead incarnation → new stream. The non-obvious failure is the **silent gap**: a server that has *some* events after the cursor but not *all* of them will happily replay the surviving tail, and the client cannot distinguish a complete resumption from a lossy one unless the server asserts contiguity. ## Operational Effect When the boundary is crossed without being detected, the observable symptoms are: duplicated side-effecting tool calls (the client re-issues work it believes was lost), causal-ordering violations in the event stream (events with IDs that appear monotonic but are semantically out of order because they came from a different counter), and "phantom resumptions" where a client believes it holds a continuous stream but has silently skipped events. The correct operational posture is to make the event log **session-scoped and authoritative** (shared store or sticky routing to the owning replica), to make event IDs **session-global**, and to have the server **assert contiguity explicitly** on resumption rather than infer it from session liveness. Migration from legacy SSE must additionally strip any client-persisted `Last-Event-ID` that predates the current session, because legacy clients carry cursors across sessions that the new transport cannot honor. ```python class ResumeContinuityError(RuntimeError): """Raised when a Last-Event-ID resumption cannot be honored as a continuation.""" pass class StreamIncarnation: def __init__(self, stream_id: str, owner_replica: str, head_id: int): self.stream_id = stream_id self.owner_replica = owner_replica self.head_id = head_id # IDs actually present in the authoritative log for this incarnation. self.present_ids: set[int] = set() def resume_stream( session_active: bool, last_event_id: int, incarnation: StreamIncarnation | None, current_replica: str, ) -> list[int]: """Return the events to replay, or raise if the cursor is not a valid continuation.""" if not session_active: raise ResumeContinuityError( f"session inactive; Last-Event-ID={last_event_id} is a new stream, not a resume" ) if incarnation is None: raise ResumeContinuityError( f"no stream incarnation owns Last-Event-ID={last_event_id}; refusing silent replay" ) if incarnation.owner_replica != current_replica: raise ResumeContinuityError( f"Last-Event-ID={last_event_id} owned by replica " f"{incarnation.owner_replica!r}, not {current_replica!r}; " "redirect to owner or treat as new stream" ) # Contiguity check: every ID from last_event_id+1 .. head must be present. expected = set(range(last_event_id + 1, incarnation.head_id + 1)) missing = expected - incarnation.present_ids if missing: raise ResumeContinuityError( f"Last-Event-ID={last_event_id} has a gap; missing {sorted(missing)} " "between cursor and head — replaying would be lossy, treat as new stream" ) return sorted(expected) ```