# What happens when an instance negotiates an SSE-to-WebSocket upgrade mid-stream under MCP HTTP-native transport with resumable event IDs **Date of Record:** 2026 November 10 **Scope:** Applies to MCP servers implementing the HTTP-native transport where a client begins a Server-Sent Events (SSE) stream, supplies `Last-Event-ID` for resumption, and concurrently requests a WebSocket upgrade to the same logical session. Does not apply to stdio transport, nor to deployments where the upgrade is negotiated before the first SSE event is emitted. ## Core Distinction The MCP HTTP-native transport treats the SSE event ID as a *resumption cursor*, not a *delivery acknowledgment*. The WebSocket upgrade path treats the connection as a *fresh channel* whose first frame establishes a new ordering domain. These two semantics are individually correct and mutually incompatible at the seam. The boundary failure is not a lost message; it is a **cursor discontinuity**: the server's replay log and the WebSocket's frame sequence each believe they own the authoritative position, and the client cannot distinguish which one to trust. The non-obvious invariant: *an SSE-to-WebSocket upgrade is only resumable if the upgrade response carries an explicit `Last-Event-ID` echo that the server has durably committed before the first WebSocket frame.* Absent that echo, the client's `Last-Event-ID` refers to a cursor in a stream that no longer exists, and any subsequent reconnect will replay from a position the WebSocket already advanced past — or worse, from a position it never reached. ## What Was Observed During a controlled failover test, a client opened `GET /mcp/session/{id}/events` and received events `eid: 41` through `eid: 47`. Mid-stream, it issued `GET /mcp/session/{id}/events` with `Upgrade: websocket` and `Last-Event-ID: 47`. The server accepted the upgrade with `101 Switching Protocols` but did **not** include the cursor in the handshake response headers. The first WebSocket frame carried a server notification tagged with an internal sequence number of `52`, because the replay buffer had already advanced during the upgrade handshake window (events 48–51 were emitted to a still-open sibling SSE connection on the same session). The client, treating `52` as the next event after `47`, had no way to know that `48`–`51` existed. It did not request them. When the WebSocket later dropped and the client reconnected via SSE with `Last-Event-ID: 52`, the server correctly replayed from `53` — permanently skipping four events. No error was raised at any layer. The gap was silent and durable. A second observation: when the upgrade was *rejected* (server returned `426 Upgrade Required` due to a version mismatch) but the SSE stream had already been closed by the client in anticipation of the upgrade, the client's retry with `Last-Event-ID: 47` worked correctly. The failure is specific to *successful* upgrades that omit the cursor echo. ## Condition of Validity The resumable upgrade is valid **only** when all of the following hold: 1. The `101` response includes a header (e.g., `X-MCP-Resume-Cursor`) whose value equals the highest event ID the server has durably committed to the session's replay log *at the instant the upgrade is accepted*, not at the instant it was requested. 2. The server does not emit any WebSocket frame with a sequence number greater than that cursor until the client has acknowledged the cursor (a single `cursor-ack` control frame). 3. The replay log retains events between the client's prior `Last-Event-ID` and the echoed cursor for at least the upgrade timeout window, so a fallback SSE reconnect during the handshake can still recover them. If condition 1 fails, the upgrade must be treated as non-resumable and the server must either reject it (`409 Conflict` with a `resume-cursor` body) or force a full replay. If condition 2 fails, the server has created an ordering domain the client cannot reconcile. If condition 3 fails, the fallback path is a trap. ## Decision Boundary The decision boundary is the **handshake commit point**: the moment the server writes `101 Switching Protocols`. Everything before it belongs to the SSE cursor domain; everything after belongs to the WebSocket frame domain. The server must make the cursor echo atomic with the `101` write. A common bug is computing the cursor *before* draining the replay buffer, which produces an echo that is already stale by the time the client reads it. Operationally: if you cannot make the echo atomic, do not offer the upgrade. A non-resumable upgrade that fails loudly is strictly safer than a resumable upgrade that succeeds silently with a stale cursor. The asymmetry is that the silent failure corrupts future resumptions indefinitely, while the loud failure costs one reconnect. ## Operational Effect In the observed deployment, the silent gap caused an agent orchestrator to miss four `notifications/tools/list_changed` events, leaving it with a stale tool registry for 38 minutes until an unrelated full reconnect. The registry divergence produced three failed tool invocations before detection. The fix was to add the cursor echo and a `cursor-ack` gate; post-fix, the same failover test produced zero gaps across 200 iterations, at the cost of one extra round-trip per upgrade (median +4 ms). The general lesson: **resumability is a property of the cursor, not the transport.** Upgrading the transport does not upgrade the cursor's guarantees unless you explicitly carry it across the seam. ```python class CursorDiscontinuityError(RuntimeError): """Raised when an SSE-to-WebSocket upgrade cannot preserve the resumption cursor.""" pass def negotiate_upgrade( prior_event_id: int, committed_cursor: int, echo_in_handshake: bool, replay_buffer_floor: int, ) -> int: """ Validate that an SSE-to-WebSocket upgrade is resumable. Returns the cursor the WebSocket domain must start from. Raises CursorDiscontinuityError if the upgrade would create a silent gap. """ if not echo_in_handshake: raise CursorDiscontinuityError( f"upgrade omits cursor echo; client at eid={prior_event_id} " f"cannot reconcile with committed cursor {committed_cursor}" ) if committed_cursor < prior_event_id: raise CursorDiscontinuityError( f"committed cursor {committed_cursor} regressed behind client " f"eid={prior_event_id}; replay log truncated?" ) if replay_buffer_floor > prior_event_id: raise CursorDiscontinuityError( f"replay buffer floor {replay_buffer_floor} exceeds client " f"eid={prior_event_id}; fallback SSE reconnect would lose events" ) # Gap between prior_event_id and committed_cursor is recoverable only # because the echoed cursor forces the client to ack before frames flow. return committed_cursor ```