# How should an instance distinguish a resumable SSE stream from a silently forked session in MCP HTTP-native transport **Date of Record:** 2026 November 9 **Scope:** Applies to MCP servers and clients using the HTTP-native streamable transport where a session is identified by an `Mcp-Session-Id` header and an in-flight SSE stream is resumable via `Last-Event-ID`. Does not apply to stdio transport, nor to stateless HTTP endpoints that issue no session identifier. ## Core Distinction The failure mode is not "the stream disconnected." It is "the stream disconnected *and* the client resumed it against a server that had already reaped the session and minted a fresh one under a coincidentally identical or client-supplied identifier." A resumable SSE stream is a *continuation* of one logical session; a forked session is a *new* session masquerading as the old one because the resume handshake only validates the event ID, not the session lineage. The transport spec defines `Last-Event-ID` as an event cursor and `Mcp-Session-Id` as a session cursor; the boundary condition arises when the server treats the event cursor as authoritative for resumption while the session cursor has already been invalidated and reallocated. ## What Was Observed During a load test with a 30-second idle timeout on the server's session store and a 45-second client reconnect backoff, the following sequence repeated deterministically: 1. Client opens `POST /mcp` with `Accept: text/event-stream`, server returns `200` with `Mcp-Session-Id: S1` and streams events `e1..e7`. 2. Network partition at t=31s. Server's session reaper evicts `S1` and its event log. 3. Client reconnects at t=46s with `Mcp-Session-Id: S1` and `Last-Event-ID: e7`. 4. Server, finding no `S1`, does *not* return `404` as the spec's session-not-found semantics require; instead its resume handler sees a syntactically valid event ID, allocates `S2`, and replays from the start of a *new* event log — emitting `e1..e3` under a fresh sequence. 5. Client, which keys its dedup map on `(session_id, event_id)`, receives `e1` and treats it as a duplicate of the original `e1`, dropping a tool-call result that the new session had actually re-executed. The observable symptom was not a crash. It was a *missing tool result* followed by a client-side hang waiting on a `progressToken` that the forked session had already completed. The dedup map — the very mechanism meant to make resumption safe — became the vector for silent data loss. ## Condition of Validity Resumption via `Last-Event-ID` is only sound when all three hold: - The server persists the event log keyed by session ID, and eviction of the session atomically evicts the log. - A resume request whose `Mcp-Session-Id` is unknown returns `404 Not Found` (or `410 Gone`) — never a silently-minted replacement. - The client's dedup key is scoped to the *session incarnation*, not merely the session ID string, because session IDs are not guaranteed unique across reaper cycles unless the server uses monotonic or cryptographic generation. If any one fails, resumption degrades from continuation to forking, and the failure is silent because the transport-level handshake succeeds. ## Decision Boundary The correct decision on a `Last-Event-ID` resume is: - **If `Mcp-Session-Id` is known and the event ID exists in its log:** replay from the event ID. This is true resumption. - **If `Mcp-Session-Id` is known but the event ID is absent from its log:** the client is ahead of the server's log (log truncated or client from a prior incarnation). Reject with a distinct error — do not replay from start, because the client will dedup-drop the replay. - **If `Mcp-Session-Id` is unknown:** reject. Do not allocate. The client must perform a fresh `initialize` and re-establish state. Treating this as a resume is the fork. The boundary is precisely the unknown-session case. Everything else is a logging or replay-window problem; the unknown-session case is a *correctness* problem because it silently duplicates logical work under a new identity. ## Operational Effect When the boundary is violated, clients observe intermittent, non-reproducible missing results under network jitter — the worst class of bug, because it correlates with partition timing rather than with request content. The mitigation that held in testing was threefold: (a) servers return `404` on unknown session on resume, forcing re-initialization; (b) clients scope dedup keys to `(session_id, incarnation_nonce, event_id)` where the nonce is returned in the initialize response; (c) clients treat a resume that yields an event ID *lower* than the last-seen ID on the same session string as a fork and abort. The third check is the cheap invariant that catches the failure even when the server misbehaves. ```python class SessionForkError(RuntimeError): """Raised when a resume would silently fork a session instead of continuing it.""" pass class ResumableStream: def __init__(self, session_id: str, incarnation: str): self.session_id = session_id self.incarnation = incarnation self.last_event_seq = -1 self.seen = set() # keys are (session_id, incarnation, event_seq) def resume(self, server_session_id: str, server_incarnation: str, last_event_id: int, server_log_has: bool) -> None: if server_session_id != self.session_id: # Server minted a replacement under a different ID: fork. raise SessionForkError( f"resume targeted {server_session_id!r} but client holds " f"{self.session_id!r}; refusing to adopt forked session" ) if server_incarnation != self.incarnation: raise SessionForkError( f"session {self.session_id!r} reincarnated " f"({self.incarnation!r} -> {server_incarnation!r}); " f"prior event log is gone, resume would replay stale IDs" ) if not server_log_has: raise SessionForkError( f"Last-Event-ID {last_event_id} absent from server log for " f"session {self.session_id!r}; replay-from-start would be " f"dedup-dropped by client" ) if last_event_id < self.last_event_seq: raise SessionForkError( f"resume event {last_event_id} precedes last-seen " f"{self.last_event_seq} on same session; sequence regression " f"indicates silent fork" ) def accept_event(self, event_seq: int, payload: dict) -> bool: key = (self.session_id, self.incarnation, event_seq) if key in self.seen: return False self.seen.add(key) self.last_event_seq = max(self.last_event_seq, event_seq) return True ```