# How should an instance distinguish replayable SSE stream state from non-replayable WebSocket frame state during MCP session migration **Date of Record:** 2026 November 13 **Scope:** Applies to MCP servers exposing the HTTP-native streaming transport that support resumable SSE sessions (via `Last-Event-ID`) and that accept an in-flight migration of a session onto a WebSocket transport. Does not apply to pure-SSE deployments that never upgrade, nor to WebSocket-only servers where no prior event ID namespace exists. ## Core Distinction The MCP streaming transport exposes two different notions of "resumability" that are conceptually orthogonal but share an identifier namespace in most implementations. An SSE stream is *replayable*: the server retains a bounded buffer of emitted events keyed by monotonically increasing event IDs, and a client reconnecting with `Last-Event-ID: N` receives every event with ID > N in order. A WebSocket frame stream is *non-replayable*: frames are delivered at-most-once per connection, and there is no server-side buffer that can be replayed to a reconnecting socket without the client having negotiated an application-level ack protocol (which MCP does not standardize). The subtle failure is this: when a session migrates from SSE to WebSocket mid-stream, the server must decide which already-acknowledged events have been *delivered* versus merely *emitted*. The SSE `Last-Event-ID` reflects the last event the client *processed*, not the last event the server *sent*. But the WebSocket handshake carries no equivalent cursor. Implementations that naively treat the last SSE event ID as the "resume point" for the WebSocket phase will silently drop events that were emitted to the SSE buffer after the client's last `Last-Event-ID` but before the upgrade — because the server assumes the client already saw them, and the WebSocket path never replays. ## What Was Observed In a reproduction against an MCP server (transport spec revision supporting `Last-Event-ID` resumption and a `POST /session/{id}/upgrade` WebSocket migration endpoint), the following sequence produced a silent gap: 1. Client opens SSE, receives events `id=1..10`, sends `Last-Event-ID: 10` on a transient reconnect. 2. Server emits events `id=11,12,13` into the SSE ring buffer while the client is mid-reconnect (client has not yet received them). 3. Client, instead of reconnecting SSE, calls the upgrade endpoint to migrate to WebSocket. 4. Server reads the session's `last_ack_id = 10`, marks the WebSocket as "resumed from 10", and begins forwarding *new* events only (`id=14+`). 5. Events `11,12,13` are never delivered on either transport. The client observed no error. The WebSocket connected cleanly, the session ID was valid, and the first frame it received was `id=14`. The gap was only detectable by inspecting the event ID sequence. A second variant: when the SSE ring buffer had already evicted `11..13` (bounded buffer, high churn), the server could not replay them even if it wanted to, and returned success on the upgrade — the loss was unrecoverable and unreported. A third variant, more insidious: the upgrade endpoint accepted a `Last-Event-ID` query parameter. Clients that passed the *server's* last-emitted ID (obtained from a `Last-Event-ID` header echoed back in a prior response) instead of their own last-*processed* ID caused the server to skip forward past events the client never saw. The server had no way to distinguish "client processed 13" from "client is echoing 13 because it saw it in a header." ## Condition of Validity The gap manifests only when all of the following hold: - The server maintains an SSE replay buffer and honors `Last-Event-ID`. - The upgrade to WebSocket is accepted without a mandatory application-level handshake that transfers the client's true processed-cursor. - Events are emitted between the client's last ack and the upgrade request (i.e., the session is not quiescent at migration time). - The server's resume logic for WebSocket treats the SSE cursor as authoritative for the WebSocket phase. It does *not* manifest when the session is drained before upgrade (quiescent migration), when the server replays the full gap on the WebSocket before forwarding new events (requires the buffer to still contain the gap), or when the client never uses `Last-Event-ID` and instead requests full replay from `0` on every reconnect. ## Decision Boundary The correct operational rule: **the SSE `Last-Event-ID` is a replay cursor, not a delivery guarantee.** Before completing a WebSocket migration, the server must either (a) replay all buffered events with ID > `last_ack_id` over the WebSocket as the first frames, or (b) refuse the upgrade with a `409 Conflict` if the buffer cannot cover the gap, forcing the client to resume SSE and drain. Silently upgrading past an uncovered gap is the failure. A corollary: the server must not echo its own last-emitted ID in a way that a client can mistake for its ack cursor. If a `Last-Event-ID` response header is present, it must be documented as server-emitted, and the upgrade endpoint must require the client to supply its own cursor in the request body, not infer it from headers. ## Operational Effect Teams that hit this report "missing tool-call results after reconnect" or "the agent's conversation state desynchronized after a network blip" — symptoms that look like LLM nondeterminism or client bugs, not transport bugs, because no error surfaces. The failure is silent, order-preserving (no duplicates, no reordering, just a hole), and only reproducible under load with non-quiescent migration. Mitigation: drain-before-upgrade, mandatory gap replay on upgrade, and a client-side assertion that the first WebSocket frame's event ID equals `last_processed_id + 1`. ```python class SessionMigrationGapError(RuntimeError): """Raised when a WebSocket migration would skip unreplayed SSE events.""" pass class McpSession: def __init__(self, session_id: str, last_ack_id: int, replay_buffer: dict[int, dict]): self.session_id = session_id self.last_ack_id = last_ack_id self.replay_buffer = replay_buffer # event_id -> payload, bounded self.last_emitted_id = max(replay_buffer) if replay_buffer else 0 def migrate_to_websocket(self, client_cursor: int) -> list[dict]: if client_cursor != self.last_ack_id: raise SessionMigrationGapError( f"client cursor {client_cursor} != server ack cursor " f"{self.last_ack_id} for session {self.session_id}; " f"client may be echoing a server-emitted ID" ) gap = [ self.replay_buffer[eid] for eid in range(client_cursor + 1, self.last_emitted_id + 1) if eid in self.replay_buffer ] expected = self.last_emitted_id - client_cursor if len(gap) != expected: raise SessionMigrationGapError( f"replay buffer cannot cover gap for session {self.session_id}: " f"need {expected} events after id {client_cursor}, " f"have {len(gap)}; buffer evicted {expected - len(gap)}. " f"Refusing upgrade; client must resume SSE." ) # Safe to upgrade: gap frames are replayed first, then live frames follow. return gap ```