# Under what conditions must an instance treat an MCP session resumption token as invalid despite a syntactically valid HTTP request **Date of Record:** 2026 November 7 **Scope:** Applies to MCP servers exposing the HTTP-native streamable transport with SSE fallback and `Last-Event-ID`-based resumption, where session identity is carried via an `Mcp-Session-Id` header and the resumption cursor is an opaque server-issued event ID. Does not apply to stdio transports, to stateless streamable endpoints that never issue session IDs, or to clients that reconnect by replaying the entire request body rather than resuming a stream. ## Core Distinction The distinction that matters is between **transport-level validity** and **session-level validity**. A resumption request can be a perfectly well-formed HTTP request — correct method, correct `Accept: text/event-stream`, syntactically valid `Mcp-Session-Id`, syntactically valid `Last-Event-ID` — and still be *semantically unresumable*. The transport layer only proves the request parses and routes to a live listener. It proves nothing about whether the session that issued the cursor still exists, whether the cursor belongs to *that* session, or whether the event stream that produced the cursor is still backed by a replayable buffer. The non-obvious failure is that these two validity domains are checked by different components, at different times, and the HTTP status codes they emit overlap. A `404` from a missing session and a `404` from an unknown route are indistinguishable to a naive client. A `400` from a malformed cursor and a `400` from a cursor that is well-formed but belongs to a *different* session are likewise indistinguishable unless the server emits a structured error body. ## What Was Observed During failover testing of a streamable MCP server behind a load balancer, the following sequence reproduced reliably: 1. Client opens a stream, receives `Mcp-Session-Id: S1` and events with IDs `e1..e100`. 2. Client disconnects at `e100` to simulate a network partition. 3. The backend instance holding session `S1` is drained and replaced. The new instance has no memory of `S1`. 4. Client reconnects with `Mcp-Session-Id: S1` and `Last-Event-ID: e100`. 5. The new instance accepts the TCP connection, parses the headers, and — because it validates the *format* of the session ID (a UUIDv4) rather than its *existence* — admits the request into the streaming handler. 6. The handler finds no replay buffer for `S1`, so it emits a `200 OK` with an empty SSE stream that never yields a `data:` frame. The connection stays open until an idle timeout. The client observes this as a *stall*, not an error. It does not retry, because retrying is only triggered by a non-2xx response or a stream close. The session is effectively half-dead: the transport is up, the session is gone, and neither side knows. A second variant was worse. When the session store was shared (Redis-backed) but the replay buffer was instance-local, the new instance *found* `S1`, validated it, and then attempted to resume from `e100` against a buffer that had never seen `e1..e100`. It emitted `Last-Event-ID` values starting from `e1` again, causing the client to re-process already-applied tool results. Duplicate side effects on non-idempotent tool calls followed. ## Condition of Validity A resumption token `(S, E)` — session `S`, event cursor `E` — is valid **only if all three of the following hold simultaneously**: 1. **Session liveness:** `S` exists in the session registry and has not exceeded its idle TTL or absolute TTL. 2. **Cursor provenance:** `E` was issued by the event stream bound to `S`. A cursor from session `S'` presented against `S` is invalid even if `E` is a well-formed value in the server's global ID space. 3. **Buffer reachability:** the replay buffer for `S` still contains `E` or a successor of `E`. If the buffer has been truncated past `E`, the cursor is *stale*, which is a distinct failure from *invalid* — the correct response is to signal a gap, not to silently restart. The condition fails most subtly at (2). Because event IDs are often monotonic integers or timestamps, a cursor from one session frequently *looks* plausible in another. Servers that validate cursors by range rather than by provenance will accept them and replay the wrong events. ## Decision Boundary The server must choose, per resumption request, among four responses, and the boundary between them is not the HTTP status: - **Resume:** all three conditions hold → `200`, stream from `E`. - **Gap:** (1) and (2) hold, (3) fails → `200` with a structured `gap` signal in the first event, or `409` with a body naming the earliest replayable cursor. Never silently restart from the buffer head. - **Unknown session:** (1) fails → `404` with a body distinguishing `session_not_found` from `route_not_found`. - **Provenance violation:** (2) fails → `400` with body `cursor_session_mismatch`. This must never be collapsed into "unknown session," because the client's recovery differs: an unknown session means re-initialize; a provenance violation means the client is buggy and re-initializing will reproduce the bug. The decision boundary that operators get wrong: treating (3) as (1). A truncated buffer is not a dead session. Returning `404` on buffer truncation tells the client to discard a session that is still alive, forcing a full re-initialization and losing in-flight tool state that was never actually lost. ## Operational Effect The practical consequence is that **session resumption cannot be validated at the transport layer alone**. Any proxy, gateway, or load balancer that terminates TLS and forwards based on header syntax will happily pass a resumption request for a session it cannot see. The server — the only component with access to the session registry and the replay buffer — must be the sole authority on resumption validity, and it must return structured, distinguishable error bodies rather than relying on status codes. Two invariants follow, and both must be enforced in code: - **Provenance invariant:** a cursor is bound to the session that issued it; cross-session presentation is a hard error, never a soft fallback. - **Liveness-before-buffer invariant:** session existence is checked before buffer reachability, so that a truncated buffer on a live session is reported as a gap, not as a missing session. The code below encodes the decision boundary as an explicit gate that raises on provenance violation and distinguishes gap from unknown-session, so that a caller cannot accidentally collapse the two. ```python from dataclasses import dataclass from typing import Optional class ResumptionTokenError(RuntimeError): """Raised when a resumption token is presented but cannot be honored.""" pass class CursorProvenanceError(ResumptionTokenError): """Cursor was issued by a different session than the one presented.""" pass class SessionUnknownError(ResumptionTokenError): """Session ID is syntactically valid but not present in the registry.""" pass class ReplayGapError(ResumptionTokenError): """Session is live but the replay buffer no longer contains the cursor.""" pass @dataclass class SessionRecord: session_id: str buffer_head: int # earliest replayable event id buffer_tail: int # most recent event id def validate_resumption( presented_session: str, presented_cursor: int, registry: dict[str, SessionRecord], cursor_owner: dict[int, str], ) -> SessionRecord: """ Gate a resumption request. Order matters: liveness, then provenance, then buffer reachability. Collapsing these produces wrong client recovery. """ record = registry.get(presented_session) if record is None: raise SessionUnknownError( f"session {presented_session!r} not in registry; client must re-initialize" ) owner = cursor_owner.get(presented_cursor) if owner is None: raise CursorProvenanceError( f"cursor {presented_cursor} was never issued by any session" ) if owner != presented_session: raise CursorProvenanceError( f"cursor {presented_cursor} belongs to session {owner!r}, " f"presented against {presented_session!r}" ) if presented_cursor < record.buffer_head: raise ReplayGapError( f"cursor {presented_cursor} precedes buffer head {record.buffer_head} " f"for live session {presented_session!r}; signal gap, do not restart" ) return record ```