# What structural signals indicate a resumable MCP stream versus a silently forked session in HTTP-native transport **Date of Record:** 2026 November 13 **Scope:** Applies to MCP servers exposing HTTP-native transport (streamable HTTP with `Mcp-Session-Id` and SSE multiplexing) where a client reconnects via `Last-Event-ID`. Does not apply to stdio transports, nor to servers that terminate every SSE response on each request boundary without binding it to a session. ## Core Distinction A streamable-HTTP MCP session is *one logical event log* per `Mcp-Session-Id`, multiplexed across possibly many physical SSE connections. Resumption is correct only when the new connection attaches to the same log and continues from a real cursor. The non-obvious failure is that a server can accept `Last-Event-ID` and open a *fresh* event log under the same session header — the connection succeeds, the HTTP status is 200, the SSE frames parse, and the client believes it resumed. No error is surfaced. The session has silently forked: there are now two event logs bearing the same `Mcp-Session-Id`, and messages emitted to the abandoned log are lost forever. The distinguishing invariant is *monotonic event-id continuity across the session, not across the connection*. If the first event id observed on a reconnected stream is not strictly greater than the last id the client consumed (or is not present at all), the stream is a fork, not a resume. ## What Was Observed In a multiplexed deployment, a load balancer routed a resumed SSE request to a different server process than the one holding the session's event buffer. The second process recognized the `Mcp-Session-Id` (shared registry) but had no event log for it, so it lazily created an empty one and began emitting events numbered from 1. The client's `Last-Event-ID: 4821` was accepted without validation — the server treated it as an opaque hint, not a cursor to verify against a retained buffer. Symptoms observed downstream: - Intermittent "missing" tool responses that the server had emitted to the original log while the client was disconnected. - Duplicate delivery of server-initiated notifications after a *second* reconnect, because the client's cursor had regressed to the forked log's low ids. - No protocol-level error at any point. The only structural signal was that event ids on the resumed stream were *lower* than the client's prior high-water mark, and that the id sequence restarted rather than continued. A second, subtler signal: legitimate resumption replays retained events *before* live ones, so the first frames after reconnect should be backlog with ids in the gap `(last_consumed, current]`. A fork instead delivers live events with ids that violate that interval. Checking the *first* event id against the gap — not just "did we get an id" — is what separates the two cases. ## Condition of Validity Resumption is valid only if all hold: 1. The server retains a per-session event buffer keyed by `Mcp-Session-Id`, and `Last-Event-ID` is validated against it (id must exist or be the session's current high-water mark). 2. Event ids are monotonic *within the session* and never reused across reconnects or process migration. 3. A resumed connection either replays the gap or explicitly signals that the gap is unrecoverable (e.g., a 4xx/410-style terminal response), rather than silently starting fresh. 4. Session state (event log + cursor) is co-located or replicated such that any process serving the session can serve its log. If any condition fails, the transport is *connection-resumable but not session-resumable*, and clients must treat reconnect as session re-establishment, not continuation. ## Decision Boundary On reconnect, the client must decide: continue, or re-establish. The boundary is the first event id received on the new stream relative to the last consumed id `L`: - First id `> L` and within the expected gap → **resume**; accept backlog, then live. - First id `<= L`, or id sequence restarts, or no id present → **fork detected**; abort the stream, do not process frames, and force session re-establishment (new `Mcp-Session-Id`). - Server returns a terminal status for the `Last-Event-ID` → **gap unrecoverable**; re-establish and replay application-level state. The trap is treating "SSE opened successfully" as the resume signal. The resume signal is *id continuity*, and it must be checked on the first frame before any application handler consumes it. ## Operational Effect Deployments that skip the id-continuity check exhibit silent message loss under rolling restarts and cross-process routing, with loss proportional to disconnect duration and invisible in success metrics (all requests return 200). Enforcing the check converts silent loss into a detectable fork, at the cost of occasional unnecessary re-establishment when a server legitimately cannot replay a gap. The correct trade is to fail loud on fork detection: a forked session that keeps running will duplicate and drop messages indefinitely, whereas a forced re-establish is bounded and recoverable. ```python class StreamForkDetected(RuntimeError): """Raised when a resumed MCP SSE stream is not a continuation of the session log.""" pass def validate_resumption(first_event_id, last_consumed_id): """ Enforce the session-resume invariant on the first frame of a reconnected stream. first_event_id: id of the first SSE event after reconnect (None if absent). last_consumed_id: highest event id the client has already processed (None if none). """ if first_event_id is None: # No id on a resumed stream: cannot prove continuity -> treat as fork. raise StreamForkDetected( "resumed stream emitted an event without an id; " "cannot verify session-log continuity" ) if last_consumed_id is not None and first_event_id <= last_consumed_id: raise StreamForkDetected( f"event id regressed: first={first_event_id} " f"<= last_consumed={last_consumed_id}; " "server opened a fresh event log under the same Mcp-Session-Id" ) return True def attach_resumed_stream(session, sse): """Attach to a reconnected stream only if it is a true continuation.""" first = sse.next_event() validate_resumption(first.event_id, session.last_consumed_id) session.ingest(first) return session ```