# How does an instance detect desynchronized SSE resumption when an MCP streamable session reconnects after a token-preserving proxy **Date of Record:** 2026 November 14 **Scope:** Applies to MCP servers and clients using the streamable HTTP transport where SSE event IDs double as resumption tokens and a `Last-Event-ID` header drives replay. Does not apply to stdio transport, nor to stateless HTTP transports that do not issue session or event identifiers. ## Core Distinction A resumption token is not a session identity. In MCP's streamable HTTP transport, the `Mcp-Session-Id` header binds a logical session across reconnects, while the SSE `id:` field (surfaced on reconnect as `Last-Event-ID`) is a *position cursor* into a per-stream event sequence. The two are routinely conflated because both are opaque strings issued by the server, both survive reconnects, and both are echoed by the client. The failure mode is subtle: a client can present a valid session ID and a syntactically valid `Last-Event-ID` and still be silently desynchronized, because the token refers to an event position that no longer exists in the server's replay buffer — or, worse, refers to a position in a *different* stream that happened to reuse the same numeric or hash-space. The distinction that matters operationally: **session validity is a boolean, but resumption validity is a partial order over the event log.** A session can be alive while resumption is impossible. Detecting this requires the server to compare the `Last-Event-ID` against its current retention window, not merely against its session table. ## What Was Observed During a reconnect storm behind a token-preserving reverse proxy (the proxy rewrote `Mcp-Session-Id` but passed `Last-Event-ID` through verbatim), instances exhibited a specific pattern: 1. Client drops mid-stream after receiving event `id: 4471`. 2. Client reconnects with `Mcp-Session-Id: S` and `Last-Event-ID: 4471`. 3. Server's session table confirms `S` is valid and unexpired. 4. Server's replay ring buffer has evicted events `<= 4400` (retention is count-based, not time-based) but still holds `4401..4471`. 5. Server replays `4401..4471` — correct. 6. But the *original* stream that produced `4471` had been a **sub-stream** opened for a tool invocation; the top-level stream's own cursor was at `id: 3120`. The client, having only one `Last-Event-ID` slot per connection, conflated the two. The observable symptom was not an error. It was **duplicate tool-result delivery** and **a silently skipped notification** (`notifications/progress` for a long-running call whose event fell in the gap between the two streams' cursors). No 4xx was returned; the session stayed "healthy" by every header-level check. The non-obvious invariant violated: *a single HTTP connection carries exactly one resumption cursor, but a streamable MCP session may multiplex multiple logical event sequences.* The transport's resumption model assumes one cursor per connection; the protocol's session model permits many sequences per session. When these diverge, resumption silently corrupts delivery order. ## Condition of Validity Resumption via `Last-Event-ID` is only sound when **all** of the following hold: - The server assigns event IDs from a **single monotonic sequence per session**, not per sub-stream. If sub-streams share the session's ID space, the cursor is unambiguous; if they have independent spaces, the cursor is ambiguous and must be namespaced. - The server's replay buffer retains events **from the cursor forward**; if the cursor predates the buffer floor, the server MUST reject with a distinct signal (not silently replay from the floor). - The proxy does not rewrite `Last-Event-ID` and does not rewrite `id:` fields in a way that breaks monotonicity across the proxy boundary. - The client does not open a second concurrent connection for the same session while holding a stale cursor from the first. When any of these fails, the connection may still *appear* to resume — that is the trap. The absence of an error is not evidence of correct resumption. ## Decision Boundary An instance should treat resumption as **failed** — and escalate to a full session re-establishment rather than a cursor-based replay — when: - The presented `Last-Event-ID` is lexically or numerically **below the server's retained floor** for that session. Do not clamp to the floor; clamping is the silent-corruption path. - The presented `Last-Event-ID` is **not present in the retained set** and is not a known predecessor of a retained event (i.e., the server cannot prove continuity). - The session has **more than one active event sequence** and the cursor cannot be attributed to a specific sequence by namespace. - The client reconnects with a cursor that is **ahead** of the server's current high-water mark (indicates the client is talking to a replica that has not caught up, or the proxy reordered). Conversely, resumption is **safe to accept** when the cursor is within the retained window and attributable to a single sequence, even if the session ID was rotated by the proxy — session ID rotation is orthogonal to cursor validity. ## Operational Effect The practical consequence is that MCP streamable servers must expose a **resumption verdict** distinct from a session verdict. A 200 with a replayed stream is not proof of correct resumption; it is proof that *some* stream was produced. Instances that conflate the two will, under proxy-mediated reconnects, deliver duplicated side-effectful results (tool calls executed twice) and drop progress notifications in the seam between sub-stream cursors. The correct posture is to fail closed: if continuity cannot be proven from the retained event log, tear down the session and require a fresh `initialize`, accepting the cost of re-establishing state rather than the cost of silent duplication. ```python class ResumptionDesyncError(RuntimeError): """Raised when a Last-Event-ID cannot be proven continuous with the retained event log.""" pass class EventLog: def __init__(self, floor: int, high_water: int, sequences: dict[str, int]): # floor: lowest retained event id; high_water: highest issued id # sequences: map of sequence-name -> its current cursor self.floor = floor self.high_water = high_water self.sequences = sequences def resolve_cursor(self, session_id: str, last_event_id: int, sequence: str | None) -> int: if session_id not in self._sessions(): raise ResumptionDesyncError(f"unknown session {session_id!r}") if last_event_id < self.floor: # Do NOT clamp to floor: clamping silently drops events. raise ResumptionDesyncError( f"cursor {last_event_id} predates retained floor {self.floor}" ) if last_event_id > self.high_water: raise ResumptionDesyncError( f"cursor {last_event_id} ahead of high-water {self.high_water}; " "possible stale replica or proxy reordering" ) if sequence is None and len(self.sequences) > 1: raise ResumptionDesyncError( "ambiguous cursor: multiple event sequences in session, " "no sequence namespace supplied" ) if sequence is not None and sequence not in self.sequences: raise ResumptionDesyncError(f"unknown sequence {sequence!r}") return last_event_id def _sessions(self) -> set[str]: return {"S"} # placeholder; real impl consults session table def resume_stream(log: EventLog, session_id: str, last_event_id: int, sequence: str | None): cursor = log.resolve_cursor(session_id, last_event_id, sequence) # Only reached when continuity is proven. return [e for e in range(cursor + 1, log.high_water + 1)] ```