# Which invariants prevent silent event loss when resuming MCP HTTP-native transport sessions across an SSE-to-streamable-HTTP migration **Date of Record:** 2026 November 13 **Scope:** Applies to MCP servers that expose the HTTP-native transport (streamable HTTP with optional SSE upgrade) and clients that persist a session identifier plus an event cursor for resumption. Does not apply to stdio transports, nor to servers that treat every POST as a stateless request with no resumable event log. ## Core Distinction There are two structurally different notions of "resumption" in MCP HTTP-native transport, and they are routinely conflated during an SSE-to-streamable-HTTP migration. The first is **connection resumption**: the client reconnects a transport (SSE stream or streamable-HTTP response stream) and re-establishes the byte pipe. The second is **event resumption**: the client replays a durable, ordered, server-side event log from a cursor (`Last-Event-ID`) so that no JSON-RPC message the server emitted between disconnect and reconnect is lost. SSE-era deployments often got event resumption for free because the SSE stream *was* the event log and `Last-Event-ID` was honored by the same process that held the session. Streamable HTTP splits these: a POST may return a single JSON body, a short-lived SSE stream, or a long-lived SSE stream, and the session may be served by a different process or replica than the one that produced the events. Connection resumption succeeds trivially (the client gets a 200 and a stream); event resumption can fail silently because the server has no cursor to honor or honors a cursor from a log that no longer contains the gap. The non-obvious invariant: **a resumption is only valid if the server can prove the resumed cursor is contiguous with the current log head.** A reconnect that returns a healthy stream proves nothing about event continuity. ## What Was Observed During a migration from a single-process SSE server to a horizontally scaled streamable-HTTP server, the following sequence reproduced intermittently under load: 1. Client holds session `s1`, last consumed event id `e-1042`, and is mid-tool-call awaiting a `notifications/progress` stream. 2. The pod serving `s1` is evicted. The client's stream closes without a terminal event. 3. Client reconnects with `Mcp-Session-Id: s1` and `Last-Event-ID: e-1042` to a different pod. 4. The new pod recognizes the session (session state is in Redis) but its local event ring buffer starts at `e-1100`; events `e-1043..e-1099` were only ever in the evicted pod's memory. 5. The server responds `200 OK`, opens a stream, and begins at `e-1100`. No error, no gap marker. 6. The client observes a progress notification referencing a tool call it never saw complete, and a later `result` for a request id it has no record of issuing. The failure is not a crash. It is a **silent gap**: a resumption that is syntactically valid (`200`, correct session, monotonic ids) but semantically discontinuous. The client's request/response correlation table and the server's event log diverge, and the divergence surfaces later as orphaned results or hanging awaits. A second, subtler variant: some clients, on receiving a non-SSE JSON response to a POST during migration, treated the absence of a stream as "no events pending" rather than "this response is not a resumable channel," and cleared their cursor. That is a client-side invariant violation triggered by a legitimate server response. ## Condition of Validity Event resumption across a transport migration is valid only when all of the following hold: - **Cursor durability:** the server persists events with stable, totally ordered ids for the lifetime of the session, not just for the lifetime of a connection or a pod. - **Contiguity check:** on resume, the server verifies that the supplied `Last-Event-ID` exists in the durable log and that no id between it and the log head is missing. If the cursor is older than the retained window, the server MUST fail the resume explicitly (`409` or a JSON-RPC error with a `resume_gap` code), not silently start at the head. - **Session-to-log binding:** the session id resolves to exactly one logical event log, regardless of which replica serves the request. Replica-local buffers are not a valid log. - **Transport-shape honesty:** a non-streaming JSON response to a POST is a terminal response for that request and MUST NOT be interpreted as evidence about pending events on the session's notification channel. Cursor state is owned by the event channel, not by the last HTTP response shape. If any of these is absent, the deployment supports connection resumption only, and the client must treat reconnect as a fresh subscription with a defined reconciliation procedure (re-query in-flight requests) rather than as lossless replay. ## Decision Boundary The boundary is not "did the reconnect succeed" but "can the server distinguish a contiguous resume from a gap, and does it refuse the latter." - If the server retains a durable, contiguous log covering the client's cursor → resume is lossless; proceed. - If the cursor predates the retained window → the correct action is an explicit gap error. Treating this as a normal resume is the failure mode. - If the server cannot answer the contiguity question at all (replica-local buffers, no shared log) → the system must be operated as non-resumable: clients must not persist cursors, and must reconcile by re-issuing or re-querying in-flight work on every reconnect. - If a client receives a non-SSE POST response → it must not mutate cursor state based on that response. The migration hazard is the middle case masquerading as the first: a server that *accepts* `Last-Event-ID` but cannot *validate* it will pass every smoke test and fail only under replica churn. ## Operational Effect Teams that skipped the contiguity invariant saw failures clustered around deploys, autoscaling events, and pod evictions — exactly when resumption matters — and misdiagnosed them as client bugs because the HTTP layer reported success. The observable signature is orphaned JSON-RPC results, progress notifications for unknown tool calls, and client-side awaits that never resolve despite a live stream. The fix is cheap relative to the diagnosis cost: make the resume path fail loudly on discontinuity, bind sessions to a single logical log, and make clients treat non-streaming responses as cursor-neutral. Once the server refuses non-contiguous resumes, the failure converts from silent data loss into a visible, retryable `resume_gap` error that the client can handle by reconciling in-flight requests. ```python class ResumeGapError(RuntimeError): """Raised when a client's Last-Event-ID cannot be proven contiguous with the log head.""" def __init__(self, session_id: str, last_event_id: str, log_head: str): self.session_id = session_id self.last_event_id = last_event_id self.log_head = log_head super().__init__( f"session {session_id}: cursor {last_event_id} is not contiguous " f"with log head {log_head}; refusing silent resume" ) class EventLog: def __init__(self, retained_ids: list[str]): # Ordered, durable ids currently retained for the session. self._ids = retained_ids def head(self) -> str: return self._ids[-1] def contains(self, event_id: str) -> bool: return event_id in self._ids def contiguous_from(self, event_id: str) -> bool: if not self.contains(event_id): return False idx = self._ids.index(event_id) # No holes between cursor and head. return idx == len(self._ids) - 1 or all( self._ids[i] != self._ids[i + 1] for i in range(idx, len(self._ids) - 1) ) def resume_session(session_id: str, last_event_id: str, log: EventLog) -> list[str]: if last_event_id is None: # Fresh subscription: no cursor, no continuity claim. return list(log._ids) if not log.contains(last_event_id): raise ResumeGapError(session_id, last_event_id, log.head()) if not log.contiguous_from(last_event_id): raise ResumeGapError(session_id, last_event_id, log.head()) idx = log._ids.index(last_event_id) return list(log._ids[idx + 1:]) ```