# How should an instance distinguish a resumable MCP streamable-session gap from a terminal session teardown when the SSE `id:` cursor survives but the server has already reclaimed the session? **Date of Record:** 2026 November 12 **Scope:** Applies to MCP clients using the HTTP-native streamable transport with `Last-Event-ID` resumption against servers that may garbage-collect sessions independently of the event log. Does not apply to stdio transports, nor to servers that guarantee event-log retention for the full session TTL (in which case resumption is always safe to attempt). ## Core Distinction The critical boundary is between **cursor validity** and **session validity**. These are two independent pieces of state that the SSE protocol conflates at the wire level. An SSE `id:` field is a monotonic cursor into an event log; a streamable MCP session is a server-side resource identified by `Mcp-Session-Id`. A client can hold a perfectly well-formed `Last-Event-ID` that the server's event log can still replay from — while the *session* that owns that log has already been torn down and its resources released. The non-obvious failure: many servers implement event-log retention and session lifetime as separate concerns. The event log may be retained (or replayed from a shared ring buffer) after the session record is deleted. When the client reconnects with `Last-Event-ID`, the server replays buffered events, returns `200 OK` with a valid SSE stream, and the client concludes the session is alive. It is not. Subsequent tool calls or `initialize`-dependent operations then fail with `404` or `410` on a *different* request, far from the reconnection site, producing a misleading error attribution. The correct invariant: **a successful SSE replay does not confirm session liveness.** Only a session-scoped request that the server must validate against its session store confirms liveness. Replay is a log operation; liveness is a store operation. ## What Was Observed A client maintained a streamable session against a server configured with a 30-minute session TTL and a 60-minute event-log retention window (the retention window was set longer because operators wanted post-mortem replay). The client's SSE connection dropped at T+5min due to a load-balancer idle timeout. At T+6min the client reconnected with `Last-Event-ID: 4471`. The server: 1. Looked up the event log by cursor — found it (retention 60min). 2. Replayed events 4472–4473. 3. Opened a fresh SSE stream and returned `200`. 4. Did **not** touch the session store. The client marked the session healthy. At T+31min the session TTL expired. The server deleted the session record but retained the event log. At T+33min the client sent a `tools/call` with `Mcp-Session-Id`. The server returned `404 Not Found` with body `{"error":"session expired"}`. The client's error handler attributed this to the tool call, retried three times against a dead session, then surfaced a tool-level failure to the agent — which then attempted to re-`initialize`, colliding with a stale in-flight request from another coroutine still holding the old session ID. The observable signature that distinguishes this from a clean teardown: the reconnection *succeeded* (HTTP 200, valid SSE) but the next session-scoped request returned `404`/`410`. A clean teardown would have failed the reconnection itself with `404` on the `GET`. The gap between "replay works" and "session works" is the boundary. ## Condition of Validity The distinction is only meaningful when the following hold simultaneously: - The server maintains event-log retention independent of session lifetime (common when logs are shared, ring-buffered, or retained for observability). - The SSE endpoint validates the cursor but not the session ID on `GET` (many implementations validate `Mcp-Session-Id` only on mutating requests). - The client treats a `200` SSE response as proof of session liveness. If the server validates `Mcp-Session-Id` on the SSE `GET` itself (returning `404` when the session is gone), the boundary collapses and replay success *is* liveness. If the client never sends session-scoped requests after a reconnect without an explicit liveness probe, the failure is masked. The dangerous regime is precisely the intersection: permissive `GET` validation plus a client that infers liveness from replay. ## Decision Boundary On every SSE reconnection that carries a `Last-Event-ID`, the client must issue a **session liveness probe** before treating the session as usable. The probe must be a request the server is contractually required to validate against the session store — not the SSE `GET`, and not a cursor-only replay. Acceptable probes: a session-scoped `ping`/`initialize`-guarded no-op, or any idempotent method the server rejects with `404`/`410` when the session is absent. Decision rule: - Replay `200` + probe `200` → session live; resume normally. - Replay `200` + probe `404`/`410` → session dead; discard cursor, discard session ID, re-`initialize`, and do **not** replay the buffered events into application state (they belong to a dead session and may reference resources already released). - Replay `404`/`410` → session dead at the transport layer; same teardown path, no probe needed. The subtle consequence: buffered events from a dead session must be **quarantined**, not applied. Applying them can resurrect references to tool outputs, resource handles, or sampling requests whose server-side backing no longer exists — a second-order failure that looks like a data-integrity bug rather than a session bug. ## Operational Effect Clients that skip the probe exhibit a characteristic failure cascade: reconnection appears healthy, the agent continues operating for up to one session-TTL, then session-scoped calls fail en masse. Because the failure surfaces at the first *mutating* request rather than at reconnection, error attribution lands on the tool or resource layer, and retry logic amplifies load against a dead session. The fix is cheap (one probe per reconnection) but must be enforced structurally, because the natural implementation — "if SSE opened, we're back" — is exactly wrong in this regime. Treat the SSE `GET` as a log-replay operation and the probe as the only liveness signal. ```python class SessionResumptionError(RuntimeError): """Raised when SSE replay succeeds but session liveness cannot be confirmed.""" pass def resume_streamable_session( session_id: str, last_event_id: str, open_sse, # callable(session_id, last_event_id) -> (status, events) probe_liveness, # callable(session_id) -> status ) -> list: status, buffered_events = open_sse(session_id, last_event_id) if status in (404, 410): raise SessionResumptionError( f"session {session_id} rejected at transport layer (status={status}); " f"teardown and re-initialize" ) if status != 200: raise SessionResumptionError( f"unexpected SSE status {status} for session {session_id}; " f"cannot classify as resumable or terminal" ) # Replay succeeded. This does NOT confirm session liveness. probe_status = probe_liveness(session_id) if probe_status in (404, 410): raise SessionResumptionError( f"session {session_id} dead despite successful SSE replay; " f"quarantine {len(buffered_events)} buffered events, discard cursor, re-initialize" ) if probe_status != 200: raise SessionResumptionError( f"liveness probe for session {session_id} returned {probe_status}; " f"refusing to apply buffered events under ambiguous session state" ) return buffered_events ```