# Which invariants prevent streamable-session resumption from silently forking an MCP session **Date of Record:** 2026 November 8 **Scope:** Applies to MCP servers exposing the HTTP-native "streamable" transport where a client may resume an interrupted server-initiated SSE stream via a resumption token / `Last-Event-ID` after a network break, and where the server may independently re-initiate SSE pushes. Does not apply to stdio transports, to stateless per-request HTTP transports without session affinity, or to WebSocket transports where the connection itself is the session. ## Core Distinction A streamable MCP session has two identities that are easy to conflate: the **session identity** (the logical conversation, keyed by `Mcp-Session-Id` and its server-side state) and the **stream identity** (a specific server-initiated SSE stream, keyed by a per-stream resumption token). Resumption is correct only when a reconnect re-binds to the *same stream identity* and the server can prove it has not already advanced that stream past the client's `Last-Event-ID`. The invariant is: **at most one live stream may be attached to a session at a time, and every event delivered to the client must be a monotonic, gap-free continuation of exactly one stream identity.** When a server honors a resumption token by opening a *new* stream, or when the client reconnects without a token and the server silently attaches a fresh stream, you get two streams whose event sequences are both "valid" but whose union is not a continuation of either. That is a session fork, not a resumption. ## What Was Observed In a deployment where the MCP server pushed progress notifications over a server-initiated SSE stream, a client behind a flaky NAT dropped its TCP connection mid-stream after receiving event id `41`. The client reconnected with `Last-Event-ID: 41` but omitted the resumption token because its HTTP library dropped a custom header on the retry. The server treated the request as a *new* stream request (no token → no existing stream to resume), minted a fresh stream id, and began replaying from its own buffer index `0` of that new stream. Meanwhile the original stream was still considered live server-side for a grace window, and a background task continued emitting events `42, 43, 44` onto it. The client, now bound to the new stream, received events `0..n` of a fresh sequence while the server's session cursor advanced on the orphaned stream. Two symptoms followed: the client applied a duplicate tool-progress side effect (it had already seen the semantic content of `42` on the old stream before the break, then saw it again as a "new" event), and the server's per-session event counter desynchronized from the client's, so a later legitimate resume returned events the client had never seen in order. The non-obvious part: neither side logged an error. The client saw a well-formed SSE stream. The server saw a well-formed resume-or-new request. The failure was entirely in the *joint* interpretation of two individually valid protocol states. ## Condition of Validity The gap-free single-stream invariant holds only under all of the following, and fails if any is missing: 1. The resumption token and `Last-Event-ID` are **co-validated**: a resume is honored only if both identify the same stream and the server's emitted high-water mark for that stream is `>= Last-Event-ID`. A token alone, or an id alone, is insufficient. 2. The server **fences the old stream** before (or atomically with) accepting a resume: the orphaned stream must be closed and its id invalidated, so a late background emit cannot land on a stream the client has abandoned. 3. Event ids are **per-stream monotonic** and never reused across streams within a session. Global-per-session ids are acceptable only if the server guarantees no stream reuses an id range. 4. The client treats a resume response that lacks an explicit `resumed: true` (or equivalent stream-identity echo) as a **new stream**, and must reconcile its local cursor rather than assume continuity. 5. The grace window for orphaned streams is strictly shorter than the client's retry budget, so a resume cannot race a still-live original. ## Decision Boundary When a resume request arrives, the server must choose among: (a) resume the identified stream from `Last-Event-ID`; (b) reject with a resumable error and require the client to start a new session; (c) start a new stream, explicitly flagged as new. The boundary is: **if the server cannot prove stream identity and gap-freedom, it must choose (b), never (c) silently.** Choosing (c) is only safe when the response carries an unambiguous "new stream" marker and the client is contractually required to reset its cursor. Any path that returns a stream the client will interpret as a continuation, without the fencing and co-validation above, is a fork. The decision is not "did the token parse" but "can I prove this is the same stream and I have not skipped an event." ## Operational Effect Systems that enforce the invariant observe resumption as idempotent: replaying a resume yields the same continuation or a clean, typed rejection, and duplicate side effects do not occur. Systems that violate it observe intermittent, load-dependent duplication and cursor drift that only manifests under connection churn — precisely the condition least covered by happy-path tests. The practical mitigation is to make stream identity explicit on the wire (echo it in the resume response), fence orphaned streams aggressively, and have the client refuse to treat any resume response as continuous unless the echoed identity matches. Treat a missing resumption token on a reconnect as a *new session request*, not a resume. ```python import time from dataclasses import dataclass, field from typing import Dict, Optional class StreamResumptionError(RuntimeError): """Raised when a resume cannot be proven gap-free on the same stream identity.""" pass @dataclass class StreamState: stream_id: str high_water: int = -1 # last event id emitted on this stream fenced: bool = False last_emit_ts: float = 0.0 @dataclass class Session: session_id: str streams: Dict[str, StreamState] = field(default_factory=dict) active_stream_id: Optional[str] = None grace_seconds: float = 30.0 def resume_stream( session: Session, resumption_token: Optional[str], last_event_id: Optional[int], now: Optional[float] = None, ) -> StreamState: """ Honor a resume only if token and Last-Event-ID co-validate to the same, non-fenced stream and the server has not advanced past the client's cursor. Otherwise raise; never silently mint a new stream. """ now = time.time() if now is None else now if resumption_token is None or last_event_id is None: # Cannot prove identity or gap-freedom -> require a new session. raise StreamResumptionError( "resume requires both resumption_token and Last-Event-ID; " "treat reconnect as a new session request" ) stream = session.streams.get(resumption_token) if stream is None: raise StreamResumptionError( f"unknown stream identity {resumption_token!r}; refusing to fork session" ) if stream.fenced: raise StreamResumptionError( f"stream {resumption_token!r} already fenced; a new stream would fork the session" ) if last_event_id > stream.high_water: raise StreamResumptionError( f"client cursor {last_event_id} ahead of server high-water " f"{stream.high_water} on stream {resumption_token!r}" ) # Fence any other live stream so a late background emit cannot land # on a stream the client has abandoned. for sid, other in session.streams.items(): if sid != resumption_token and not other.fenced: if now - other.last_emit_ts <= session.grace_seconds: # Orphaned stream still inside grace window: do not resume # into a state where two streams are live. raise StreamResumptionError( f"orphaned stream {sid!r} still live within grace window; " "resume would create two active streams" ) other.fenced = True session.active_stream_id = resumption_token return stream ```