# How does an instance detect silent SSE resumption divergence in MCP HTTP-native streamable transport **Date of Record:** 2026 November 13 **Scope:** Applies to MCP servers and clients using the HTTP-native streamable transport with `text/event-stream` responses, `Last-Event-ID`-based resumption, and `Mcp-Session-Id` continuity across reconnects. Does not apply to stdio transports, nor to stateless HTTP servers that do not honor `Last-Event-ID` and instead replay from a fresh stream. ## Core Distinction There are two distinct notions of "resumed" in streamable MCP transport, and they are not equivalent: 1. **Transport-level resumption** — the client reconnects, sends `Last-Event-ID: N`, and the server continues emitting events with IDs `N+1, N+2, ...` on the same logical stream. The SSE framing appears continuous. 2. **Semantic-level continuity** — the *session* (identified by `Mcp-Session-Id`) still holds the same initialized state, capability negotiation, pending request map, and per-stream ordering guarantees that existed before the disconnect. The non-obvious failure is that **transport-level resumption can succeed while semantic continuity has silently diverged**. A server that has evicted the session, restarted the process, or garbage-collected the per-stream replay buffer can still return `200 OK` with a valid-looking event stream — often replaying from a *different* buffer or continuing ID numbering from a global counter. The client sees well-formed SSE with monotonically increasing IDs and concludes the stream is intact. It is not. Events may be missing, reordered relative to pre-disconnect events, or belong to a different logical stream that happens to share the ID space. The invariant that actually matters is: **event ID continuity does not imply event content continuity, and neither implies session continuity.** An instance must detect divergence independently of what the transport layer reports. ## What Was Observed In a representative deployment, a client established a streamable session, received events up to `id: 1041`, and lost the TCP connection during a rolling server restart. On reconnect it sent `Last-Event-ID: 1041` with the original `Mcp-Session-Id`. The server (now a fresh process) responded `200 OK` and began emitting events at `id: 1042`. The client resumed processing. Three failure signatures were observed across incidents: - **Gap-with-continuous-IDs:** The new process assigned IDs from a monotonic counter that had been persisted, but the replay buffer was empty. Events `1042`–`1055` were *new* events, not replayed ones. The client never received the events that had been emitted between `1041` and the crash (which had IDs `1042`–`1050` in the old process). IDs looked continuous; content had a seven-event hole. - **Session resurrection:** The server accepted the stale `Mcp-Session-Id` without validating it against its live session table (a permissive default), returning events for a *new* session that reused the same ID string. The client's negotiated capabilities from the old session were no longer in force, but the client had no signal to re-negotiate. - **Silent downgrade to replay-from-zero:** A load balancer routed the reconnect to a replica that did not share the replay buffer. That replica treated an unknown `Last-Event-ID` as "start fresh," emitting from `id: 1`. The client's dedup logic (which assumed monotonicity) accepted these as new events, causing duplicate tool-call side effects. In all three cases, the HTTP status was `200`, the `Content-Type` was `text/event-stream`, and the SSE parser raised no error. ## Condition of Validity Detection of divergence is valid only under the following conditions: - The client maintains an out-of-band record of the **last fully-processed event ID** and the **last event's semantic digest** (e.g., a hash of the event payload plus its ID), persisted across the disconnect. - The server exposes, at minimum, a session-liveness signal — either via a dedicated `GET` on the session endpoint returning `404`/`410` for unknown sessions, or via a `Mcp-Session-Id` echo that the client can compare against its stored value. - The client treats the *first* event received after reconnect as a probe: it must verify that the event's ID is exactly `last_processed_id + 1` **and** that the server did not signal a replay reset (e.g., an explicit `retry:` directive, a `X-MCP-Replay-Epoch` header, or an event with `id` ≤ `last_processed_id`). If any of these conditions is absent — notably if the server does not distinguish "replayed from buffer" from "newly generated" — divergence is **undetectable by the client alone** and the correct posture is to treat the session as untrusted and force re-initialization. ## Decision Boundary The decision procedure on reconnect is: 1. If the server returns a non-2xx status, or a `Mcp-Session-Id` that differs from the client's stored value → **session invalid**: re-initialize. 2. If the first post-reconnect event has `id > last_processed_id + 1` → **gap**: the server skipped events; re-initialize or request explicit replay of `[last_processed_id+1, first_id-1]`. 3. If the first post-reconnect event has `id ≤ last_processed_id` → **replay reset**: the server restarted its stream; re-initialize and discard prior in-flight state. 4. If the first post-reconnect event has `id == last_processed_id + 1` and the server did not advertise a replay epoch change → **provisionally continuous**: proceed, but continue to validate every subsequent event's ID monotonicity and payload digest for the remainder of the stream. The boundary is between case 4 and everything else. Case 4 is the only case in which the client may continue without re-initialization, and even then it must keep validating. The common mistake is to treat *any* `200 OK` + `text/event-stream` as case 4. ## Operational Effect When divergence is not detected, the observed operational effects are: - **Duplicate side effects** from replayed tool calls that the client believes are new (the replay-from-zero case). - **Phantom state** where the client's model of server-side session state (capabilities, subscriptions, pending request IDs) diverges from reality, causing subsequent requests to be rejected with `-32602` or silently ignored. - **Unbounded latency** when the client waits for an event that was emitted during the gap and will never be re-sent, stalling a request until timeout. When divergence *is* detected and the client re-initializes, the cost is a bounded re-negotiation (one `initialize` round-trip plus capability exchange). This is strictly cheaper than any of the phantom-state outcomes. The operational rule is therefore: **on any ambiguity in resumption continuity, prefer re-initialization over continuation.** The transport's "resumable" property is an optimization, not a correctness guarantee. ```python import hashlib from dataclasses import dataclass class SSEStreamDivergenceError(RuntimeError): """Raised when an SSE reconnect cannot be proven to preserve session continuity.""" pass @dataclass class StreamCheckpoint: session_id: str last_processed_id: int last_event_digest: str def _digest(event_id: int, payload: bytes) -> str: return hashlib.sha256(f"{event_id}:".encode() + payload).hexdigest() def validate_resumption( checkpoint: StreamCheckpoint, reconnect_session_id: str, first_event_id: int, first_event_payload: bytes, server_replay_epoch: str | None, client_replay_epoch: str | None, ) -> None: """ Validate the first event received after an SSE reconnect. Raises SSEStreamDivergenceError if the stream cannot be proven continuous. Returns None only when the stream is provisionally continuous. """ if reconnect_session_id != checkpoint.session_id: raise SSEStreamDivergenceError( f"session id changed on reconnect: " f"expected {checkpoint.session_id!r}, got {reconnect_session_id!r}" ) if server_replay_epoch != client_replay_epoch: raise SSEStreamDivergenceError( f"replay epoch mismatch: client={client_replay_epoch!r}, " f"server={server_replay_epoch!r}; replay buffer was reset" ) expected = checkpoint.last_processed_id + 1 if first_event_id <= checkpoint.last_processed_id: raise SSEStreamDivergenceError( f"replay reset: first event id {first_event_id} <= " f"last processed {checkpoint.last_processed_id}" ) if first_event_id > expected: raise SSEStreamDivergenceError( f"event gap on resume: expected id {expected}, got {first_event_id}; " f"events {expected}..{first_event_id - 1} were lost" ) # id matches; verify the server replayed the *same* event we last saw # only if the server claims to replay the boundary event. If it does not # replay the boundary, we cannot cross-check content and must rely on # monotonicity alone -- which is why the epoch check above is mandatory. if _digest(first_event_id, first_event_payload) == checkpoint.last_event_digest: # server replayed the last event we already processed; caller must dedup. return # Provisionally continuous: id == expected and epoch matched. return def resume_stream( checkpoint: StreamCheckpoint, reconnect_session_id: str, first_event_id: int, first_event_payload: bytes, server_replay_epoch: str | None, client_replay_epoch: str | None, ) -> None: try: validate_resumption( checkpoint, reconnect_session_id, first_event_id, first_event_payload, server_replay_epoch, client_replay_epoch, ) except SSEStreamDivergenceError: # Caller must re-initialize the session rather than continue. raise ```