# Where must an instance draw the line between SSE resumption and session re-establishment in MCP HTTP-native streamable transport **Date of Record:** 2026 November 8 **Scope:** Applies to MCP servers and clients using the HTTP-native streamable transport where the server emits `text/event-stream` responses that may be resumed via `Last-Event-ID`, and where session continuity is carried by a session identifier (e.g. `Mcp-Session-Id`). Does not apply to stdio transports, nor to servers that treat each request as fully stateless with no event-id lineage. ## Core Distinction There are two independent continuity mechanisms in this transport, and they fail separately: 1. **Stream resumption** — the client reconnects to an in-flight SSE stream and supplies `Last-Event-ID` so the server replays events after that id. This is a *per-stream* cursor. 2. **Session continuity** — the client presents its session identifier so the server can locate the session state (subscriptions, pending tool calls, negotiated capabilities). This is a *per-session* identity. The non-obvious invariant is this: **a valid `Last-Event-ID` is not evidence of a valid session, and a valid session does not imply the event-id lineage is still replayable.** The two are orthogonal. The boundary failure occurs when a server accepts a resumption request on the strength of the event id alone (replaying from a buffer keyed by stream) while the session it belongs to has been evicted or rotated — or, symmetrically, when a server validates the session but silently discards the event-id cursor because the buffer was garbage-collected, then emits a fresh stream whose first event id is unrelated to the client's cursor, causing the client to believe it has missed events that were actually never produced. ## What Was Observed In a deployment where sessions had a 30-minute idle TTL and SSE event buffers were retained per-stream for 5 minutes after the stream's last write, a client reconnected after a 6-minute network partition with a correct `Last-Event-ID` and a correct `Mcp-Session-Id`. The session had *just* expired (idle 31 minutes due to the partition). The server's resumption handler ran in this order: 1. Look up stream buffer by session id → miss (session evicted, buffer dropped). 2. Fall through to "new stream" path → allocate new stream, new event-id namespace starting at 1. 3. Return `200` with `text/event-stream`, first event id `1`. The client, holding `Last-Event-ID: 4821`, interpreted the new stream as a resumption whose cursor had regressed. Depending on client strictness this produced either (a) a silent gap — the client dropped events 1..N believing 4821+ was authoritative, or (b) a hard error, but only *after* the client had already re-sent a tool call whose result had been delivered as event 4820 in the lost buffer. The tool call executed twice. The second observed mode: session *rotated* (server-side failover) but event buffer survived on the old node. Client presented old session id; new node had no session, but the old node's buffer was reachable via a shared store. Server replayed events for a session that no longer existed, including a `tools/call` result that the new session had already re-executed. Two results for one logical call. ## Condition of Validity The resumption protocol is only sound under the conjunction: - The session identifier presented is currently live **and** refers to the same logical session incarnation that produced the event ids in the client's cursor. (Incarnation, not just identity — a rotated session with the same id string is a different incarnation.) - The event buffer for that incarnation still contains events strictly after `Last-Event-ID`, **or** the server explicitly signals that the cursor is unreplayable rather than silently starting a new namespace. If either conjunct fails, the server is obligated to refuse resumption with a distinguishable status (not `200`), so the client can decide between full re-establishment and surfacing an error to the caller. A `200` with a regressed or unrelated event-id namespace is the specific forbidden state: it is indistinguishable at the transport layer from a legitimate replay, and the ambiguity is resolved only by application-level event content, which is exactly what the client cannot inspect before acting. ## Decision Boundary The decision point is at the server's resumption handler, and it must be a **three-way** branch, not two: - **Replayable:** session live, same incarnation, buffer covers cursor → replay from cursor. - **Session invalid:** session missing or different incarnation → respond with a session-terminated signal (e.g. `404` on the session, or a `4xx` carrying a machine-readable `session_expired` code). Do **not** open a stream. The client must re-initialize. - **Session valid, cursor unreplayable:** session live and same incarnation, but buffer gap → respond `200` on the stream but emit an explicit `event: resync` (or equivalent) as the first event, carrying the earliest available id and a flag that the gap is unrecoverable. The client must then choose to accept the gap or re-establish; the server must not pretend continuity. The boundary is *not* "did the client send a plausible event id." It is "does the (session incarnation, event-id) pair denote a position the server can actually honor." Anything else is a fabricated continuity claim. ## Operational Effect When the boundary is drawn correctly, a mid-stream partition costs at most one explicit resync event and never a duplicated side effect. When it is drawn incorrectly — the observed failure — the cost is silent double execution of non-idempotent tool calls, which is unrecoverable at the transport layer because the client has no way to distinguish replay from fresh stream until after it has acted. The practical mitigation is to make the incarnation part of the event-id namespace (or bind it cryptographically) so that a cursor from a dead incarnation cannot be mistaken for a live one, and to make the resync signal a first-class event rather than an out-of-band status, so that clients that ignore status codes still cannot miss it. ```python class ResumptionBoundaryError(RuntimeError): """Raised when a resumption request cannot be honored without fabricating continuity.""" pass def handle_resumption(session_store, event_buffer, session_id, last_event_id): session = session_store.get(session_id) if session is None: # Session missing: refuse. Do NOT open a stream with a fresh id namespace. raise ResumptionBoundaryError( f"session {session_id!r} not found; client must re-initialize" ) incarnation = session.incarnation cursor = event_buffer.parse_cursor(last_event_id) if cursor is None: raise ResumptionBoundaryError( f"unparseable Last-Event-ID {last_event_id!r}" ) if cursor.incarnation != incarnation: # Cursor belongs to a dead incarnation. Same id string, different session. raise ResumptionBoundaryError( f"cursor incarnation {cursor.incarnation!r} != live {incarnation!r}; " "refusing to replay across incarnation boundary" ) if not event_buffer.covers(incarnation, cursor.seq): # Session is valid but the gap is unrecoverable. Must signal resync, # never silently restart the namespace. return ResyncStream( session_id=session_id, incarnation=incarnation, earliest_available=event_buffer.earliest_seq(incarnation), requested=cursor.seq, ) return ReplayStream( session_id=session_id, incarnation=incarnation, events=event_buffer.since(incarnation, cursor.seq), ) ```