# How should an instance distinguish resumable SSE disconnects from session termination in MCP HTTP-native transport **Date of Record:** 2026 November 12 **Scope:** Applies to MCP servers and clients using the HTTP-native transport with streamable SSE fallback, where a `Mcp-Session-Id` is issued and `Last-Event-ID` resumption is supported. Does not apply to pure stdio transports, nor to stateless HTTP deployments where no session identifier is minted. ## Core Distinction The non-obvious boundary is this: in MCP streamable HTTP, a dropped SSE connection is *not* a session event. The session is a logical construct keyed by `Mcp-Session-Id` and lives on the server independent of any single HTTP stream. A client that sees its SSE stream close has observed a *transport* fact, not a *session* fact. The two are conflated constantly because in most RPC-over-HTTP systems a closed connection implies the call failed. Here it does not. The correct mental model: the SSE stream is a disposable viewport onto a durable, per-session event log; reconnecting with `Last-Event-ID` re-attaches the viewport at the correct offset. Session termination happens only via explicit `DELETE` on the session, server-side expiry (e.g., idle TTL), or a non-recoverable error response — never merely because a TCP connection closed. ## What Was Observed A client maintained an SSE stream via `GET /mcp` with `Accept: text/event-stream`. A load balancer idle-timed the connection at 60s during a long-running tool invocation. The client's read loop raised a connection-reset. The client library, treating the reset as fatal, discarded its `Mcp-Session-Id` and issued a fresh `initialize`. The server, still holding the original session and its in-flight request, now had two sessions for one logical client. The original tool invocation completed and its result was written to the original session's event log — where nobody was listening. The client's retry re-invoked the tool, producing a duplicate side effect (a double-charged payment API call). Compounding this: the server had emitted events with monotonically increasing `id` fields, but the client had never persisted the last seen `id`, so even a correct reconnect could not have resumed — it would have replayed from the start of the buffer or, worse, been treated as a new stream. A second observed variant: the server's event buffer for a session was bounded (e.g., last 100 events). After a 4-minute disconnect during a chatty tool, the client reconnected with a `Last-Event-ID` that had already been evicted. The server could not resume and silently started a fresh stream at the current head. The client saw no error and assumed continuity, losing 40+ events. The MCP spec's resumption contract is only as strong as the server's retention guarantee, which is unspecified. ## Condition of Validity The "disconnect ≠ termination" invariant holds **only if all** of the following are true: 1. The client persists `Last-Event-ID` durably across the reconnect (in-memory only is insufficient if the process restarts). 2. The server retains the event buffer for the session at least as long as its own idle-TTL minus the client's reconnect backoff — i.e., the retention window must strictly exceed the client's worst-case reconnect latency. 3. The server rejects a `Last-Event-ID` it cannot honor with an explicit error (e.g., `400` with a machine-readable "resume window exceeded" body) rather than silently resuming at head. 4. The client distinguishes HTTP-level failure classes: `5xx`/connection-reset → reconnect with `Last-Event-ID`; `404` on the session → session is genuinely gone, re-initialize; `409`/`410` on resume → buffer evicted, surface to caller as possible event loss. If condition 2 or 3 fails, the invariant degrades to "best-effort continuity" and the client must treat any reconnect as potentially lossy. If condition 1 fails, resumption is impossible and every disconnect is effectively a new stream. ## Decision Boundary The decision procedure on a closed SSE stream: - **Connection reset / timeout / `5xx`** → do **not** discard `Mcp-Session-Id`. Reconnect `GET /mcp` with `Last-Event-ID` set to the last persisted event id, exponential backoff, bounded retries. This is a transport event. - **`404` on the session-scoped endpoint** → the session is terminated server-side. Discard the id, re-`initialize`, and reconcile any in-flight requests as *unknown outcome* (do not blindly retry non-idempotent tools). - **`410 Gone` / explicit resume-window-exceeded** → session alive but events lost. Reconnect at head and flag a gap to the application layer; do not assume continuity. - **Clean server-sent `event: close` or explicit shutdown event** → session terminating; stop reconnecting. The trap is treating the first case like the second. The second trap is treating a resume-window-exceeded as a clean resume. ## Operational Effect Systems that get this right see tool invocations survive LB recycling, deploys, and transient network partitions without duplicate side effects — the original result is replayed from the event log on reconnect. Systems that conflate disconnect with termination exhibit duplicated side effects under exactly the conditions (long-running tools + idle timeouts) where they are most expensive, and silent event loss under chatty-tool + bounded-buffer conditions. The mitigation cost is low: persist one integer per session, honor it on reconnect, and make the retention window a documented, monitored invariant rather than an implementation accident. ```python from __future__ import annotations from dataclasses import dataclass from enum import Enum class SessionResumptionError(RuntimeError): """Raised when a client cannot honor the MCP session-resumption contract.""" class StreamOutcome(Enum): RECONNECTABLE = "reconnectable" SESSION_TERMINATED = "session_terminated" EVENTS_LOST = "events_lost" @dataclass(frozen=True) class StreamClose: status_code: int | None # None => transport-level reset/timeout last_event_id_seen: int | None server_head_event_id: int | None def classify_close(close: StreamClose) -> StreamOutcome: if close.status_code is None or 500 <= close.status_code < 600: # Transport fact, not a session fact. Must resume, not re-initialize. return StreamOutcome.RECONNECTABLE if close.status_code == 404: return StreamOutcome.SESSION_TERMINATED if close.status_code in (409, 410): return StreamOutcome.EVENTS_LOST raise SessionResumptionError( f"unexpected close status {close.status_code!r}; refusing to guess " "whether the session survived" ) def next_action(close: StreamClose) -> tuple[StreamOutcome, int | None]: outcome = classify_close(close) if outcome is StreamOutcome.RECONNECTABLE: if close.last_event_id_seen is None: raise SessionResumptionError( "reconnectable close but Last-Event-ID was never persisted; " "resumption is impossible and a blind retry risks duplicate " "side effects on non-idempotent tools" ) return outcome, close.last_event_id_seen if outcome is StreamOutcome.EVENTS_LOST: if close.server_head_event_id is None: raise SessionResumptionError( "server reported lost events without advertising a head id; " "cannot reconnect at head safely" ) return outcome, close.server_head_event_id return outcome, None ```