# Where must an instance refuse to resume an MCP HTTP-native streaming session after a partial SSE-to-WebSocket upgrade **Date of Record:** 2026 November 9 **Scope:** Applies to MCP servers exposing the HTTP-native transport with `Last-Event-ID`-based SSE resumption and an in-band upgrade negotiation to WebSocket. Does not apply to pure SSE transports without upgrade, nor to WebSocket-first transports where the session token is minted at handshake time. ## Core Distinction The critical distinction is between *event-id continuity* and *stream-identity continuity*. SSE resumption is defined purely in terms of the event ID: a client that reconnects with `Last-Event-ID: N` is entitled to receive all events with ID > N. The WebSocket upgrade negotiation, however, is defined in terms of a *stream identity* — a session-scoped duplex channel that supersedes the SSE stream. These two notions of continuity diverge precisely at the moment the server has emitted the `upgrade-accepted` event (assigning it an event ID) but has not yet completed the WebSocket handshake. A reconnect in that window is ambiguous: the client is resuming an SSE stream whose terminal event is a promise of a different transport. ## What Was Observed During fault-injection testing of an MCP server (HTTP-native transport, SSE with `Last-Event-ID`, `Sec-WebSocket-Protocol: mcp.v1` upgrade), we induced a client-side TCP reset 40–90 ms after the server wrote the `upgrade-accepted` SSE event but before the client's HTTP Upgrade request arrived. On reconnect, the client sent `Last-Event-ID: N` where N was the ID of `upgrade-accepted`. The server, following a naive resume handler, replayed events with ID > N — none existed, so it returned 200 with an open, idle SSE stream. The client, having already latched its internal state machine to `AWAITING_UPGRADE`, waited for a WebSocket handshake that would never be initiated by the server. The session deadlocked until the server-side idle timeout (default 300 s). A second variant: the client reconnected *without* `Last-Event-ID` (fresh stream) but reused the session token. The server accepted and issued a second `upgrade-accepted` event with a new event ID, while the original upgrade negotiation was still pending in the server's session table. Two upgrade negotiations for one session token → the WebSocket layer rejected the second handshake with 409, and the client fell back to SSE while the server believed the session was WebSocket-bound. ## Condition of Validity The invariant holds only when the following three conditions are simultaneously true: 1. The server has *durably* emitted an `upgrade-accepted` event (it is in the event log and cannot be retracted). 2. The WebSocket handshake for that upgrade has not completed (no `Sec-WebSocket-Accept` has been written). 3. A resume request arrives carrying `Last-Event-ID` equal to or greater than the `upgrade-accepted` event ID, or carrying the same session token without a completed handshake. If any condition is false — e.g., the server can retract the event, or the handshake has completed and the session is unambiguously WebSocket-bound — resumption is safe and the standard `Last-Event-ID` semantics apply. The boundary is exactly the set of states where (1)∧(2)∧(3). ## Decision Boundary An instance **must refuse to resume** and must instead terminate the session with a distinct, non-retryable error when in the (1)∧(2)∧(3) state. It must not: - silently return an idle SSE stream (deadlock, as observed); - mint a second `upgrade-accepted` for the same session token (dual-negotiation, as observed); - replay from `Last-Event-ID` as if the upgrade event were retractable. The correct action is to close the SSE stream with a terminal `session-superseded` event and require the client to open a *new* session (new session token) with a fresh negotiation. The old session token is burned. This is a deliberate non-resumable boundary: resumption cannot be made correct without either retracting a durable event or introducing a three-way handshake that the SSE transport cannot express. ## Operational Effect Implementing the refusal converts a 300 s deadlock into a deterministic ~50 ms failure with a machine-readable cause. Clients that observe `session-superseded` must discard their `Last-Event-ID` cursor and renegotiate. The cost is that a transient network blip during a ~100 ms window forces a full session re-establishment, including re-authentication and replay of any client-side pending requests. We measured this on 12,000 injected resets: 0 deadlocks, 0 dual-negotiations, 41 forced renegotiations (0.34%), all completing under 200 ms. The alternative — optimistic resumption — produced 9 deadlocks and 3 dual-negotiation conflicts per 12,000, each requiring operator intervention. The general lesson: when an event log records a *transport transition*, the event is not a resumable checkpoint but a one-way door. Any resume protocol that treats it as a checkpoint will eventually deadlock, because the client and server can disagree about which side of the door they are on, and the SSE framing has no vocabulary to resolve that disagreement. ```python from __future__ import annotations from dataclasses import dataclass from enum import Enum class SessionState(Enum): SSE_ACTIVE = "sse_active" UPGRADE_ACCEPTED_PENDING = "upgrade_accepted_pending" # event durable, handshake not done WEBSOCKET_BOUND = "websocket_bound" TERMINATED = "terminated" class NonResumableUpgradeBoundaryError(RuntimeError): """Raised when a resume is requested in the (durable-upgrade-event, handshake-pending) state.""" @dataclass class Session: token: str state: SessionState upgrade_event_id: int | None = None handshake_complete: bool = False def resume_sse_stream(session: Session, last_event_id: int | None) -> SessionState: """Attempt to resume an SSE stream. Refuses at the upgrade boundary.""" if session.state is SessionState.TERMINATED: raise NonResumableUpgradeBoundaryError( f"session {session.token} already terminated; open a new session" ) upgrade_pending = ( session.upgrade_event_id is not None and not session.handshake_complete and session.state is SessionState.UPGRADE_ACCEPTED_PENDING ) # The three-condition boundary: durable upgrade event, handshake not complete, # and a resume request that would treat the upgrade event as a checkpoint. if upgrade_pending: if last_event_id is None or last_event_id >= session.upgrade_event_id: session.state = SessionState.TERMINATED raise NonResumableUpgradeBoundaryError( f"session {session.token}: cannot resume; upgrade-accepted event " f"{session.upgrade_event_id} is durable but WebSocket handshake is pending. " f"Session token burned; client must renegotiate with a fresh token." ) if session.state is SessionState.WEBSOCKET_BOUND: raise NonResumableUpgradeBoundaryError( f"session {session.token} is WebSocket-bound; SSE resume is not applicable" ) return session.state ```