# Under what conditions must an instance abandon SSE resumption and re-establish a fresh MCP session rather than replaying from `Last-Event-ID` **Date of Record:** 2026 November 14 **Scope:** Applies to MCP servers speaking the Streamable HTTP transport with resumable Server-Sent Events, fronted by a load balancer that does *not* guarantee session affinity, where the tool server itself is stateless (holds no per-session server-side state between requests). Does not apply to stateful MCP servers that persist an event log keyed by session ID, nor to pure stdio transports. ## Core Distinction Resumability and session continuity are orthogonal properties that the Streamable HTTP transport composes but does not couple. Resumability is a property of the *event stream*: the client presents `Last-Event-ID` and the server replays events after that ID. Session continuity is a property of the *session*: the `Mcp-Session-Id` header identifies a logical conversation whose server-side context (initialization handshake, negotiated capabilities, in-flight tool calls) must be honored. A stateless tool server can offer resumability without offering continuity — it can replay the *bytes* of a stream it no longer owns, but it cannot reconstruct the *context* under which those bytes were emitted. The failure mode is treating a successful replay as a successful resume. ## What Was Observed A stateless MCP tool server behind a round-robin LB without sticky sessions. Client opens a POST to `/mcp`, receives `Mcp-Session-Id: S1` and an SSE stream. Mid-stream, the LB drops the connection (idle timeout at 30s, common default). Client reconnects with `Last-Event-ID: 41` and — because the LB is round-robin — lands on instance B, which never saw session S1. Instance B, being stateless and permissive, looks up event 41 in a *shared* event store (e.g., Redis stream keyed by session ID) and replays events 42–47. The client believes the stream resumed. The corruption: events 42–47 included a `tools/call` result whose `id` referenced a request the client had dispatched on instance A. Instance B replayed the result but had no record of the request, so when the client later sent a *dependent* follow-up tool call, instance C (next round-robin hop) rejected it as referencing an unknown request — or worse, silently executed it against a fresh session with default capabilities, skipping the `initialize` handshake that had negotiated a non-default sampling temperature. The agent executed three tool calls under the wrong capability set and produced a plausible but wrong answer. No error was surfaced to the client at any hop. A second, subtler observation: the replayed stream terminated *cleanly* with a normal SSE `end` event. Clients interpret clean termination as successful completion. There is no transport-level signal distinguishing "stream ended because work finished" from "stream ended because the resuming instance had no continuation to offer." ## Condition of Validity Replay-from-`Last-Event-ID` is valid **if and only if** the resuming instance can prove all three of the following, atomically, before emitting the first replayed event: 1. **Session ownership**: it holds the authoritative session record for the presented `Mcp-Session-Id`, including negotiated capabilities and the `initialize` result hash. 2. **Event-log continuity**: the event store contains every event from `Last-Event-ID + 1` through the current head with no gap (a monotonic, gap-free sequence — not merely "the ID exists"). 3. **Request-context presence**: for any replayed event that is a *response* to a client request, the request record is still resolvable, so dependent follow-ups can be validated. If any of the three fails, replay is a *lie*: it delivers bytes whose meaning depends on context the resuming instance cannot supply. The correct action is to reject the resume and force a fresh session — surfacing a distinguishable error so the client re-runs `initialize` and re-dispatches idempotent work. The condition is *not* satisfied by "the event ID exists in a shared store." That is the trap. Existence of the byte is not continuity of the session. ## Decision Boundary The boundary is crossed when `session_owner(resuming_instance) != session_owner(originating_instance)` **and** the server is stateless with respect to session context. Concretely, abandon resumption and require a fresh session when *any* of: - The `Mcp-Session-Id` is unknown to this instance and the server does not replicate full session context. - The event store has a gap between `Last-Event-ID` and the current head (sequence numbers are not contiguous). - A replayed event is a response whose originating request is not in the resolvable request set. - The negotiated-capability hash recorded at `initialize` cannot be confirmed equal to the hash in force when the stream was opened. Do **not** cross the boundary — i.e., replay is safe — when the server maintains a replicated session log with gap-free sequencing and request records survive across instances. In that case affinity is an optimization, not a correctness requirement. The dangerous middle ground, and the reason this is non-obvious: a server that replicates the *event log* but not the *request records* passes naive resumption tests (the stream replays, IDs match) while failing the dependent-call invariant. Test for the invariant, not the replay. ## Operational Effect When the boundary is respected, the client receives an explicit `SESSION_RESUME_REJECTED` (a 4xx with a machine-readable code, not a clean stream end), re-runs `initialize`, and re-dispatches only idempotent tool calls. The cost is a full handshake and possible duplicate side effects for non-idempotent calls — mitigated by requiring idempotency keys on `tools/call`. When the boundary is violated, the failure is silent: streams replay, agents proceed, and wrong-capability or orphaned-request executions surface as plausible-but-incorrect outputs with no error trail. The empirical rule: **a stateless MCP server must never honor `Last-Event-ID` across instances; it must reject and force re-initialization.** Resumption is a stateful-server feature. Statelessness and resumability are mutually exclusive across instance boundaries. ```python from __future__ import annotations from dataclasses import dataclass class SessionResumeRejected(RuntimeError): """Raised when a resumable-SSE resume cannot be honored safely. Signals the client to abandon Last-Event-ID replay, re-run the MCP initialize handshake, and re-dispatch only idempotent tool calls. """ @dataclass(frozen=True) class ResumeRequest: session_id: str last_event_id: int resuming_instance: str originating_instance: str capability_hash: str @dataclass(frozen=True) class SessionRecord: session_id: str owner_instance: str capability_hash: str event_head: int event_ids: frozenset[int] resolvable_request_ids: frozenset[str] def authorize_resume( req: ResumeRequest, record: SessionRecord | None, replayed_response_request_ids: frozenset[str], ) -> None: """Permit replay only if ownership, continuity, and context all hold. Raises SessionResumeRejected on any violation; never returns a partial success, because a partial replay is indistinguishable from a completed stream at the transport layer. """ if record is None or record.session_id != req.session_id: raise SessionResumeRejected( f"unknown session {req.session_id!r} on instance " f"{req.resuming_instance!r}; fresh initialize required" ) if record.owner_instance != req.resuming_instance: # Stateless server: a different instance cannot prove context. raise SessionResumeRejected( f"session {req.session_id!r} owned by {record.owner_instance!r}, " f"resume attempted on {req.resuming_instance!r}; " "stateless server must not replay across instances" ) expected = set(range(req.last_event_id + 1, record.event_head + 1)) missing = expected - record.event_ids if missing: raise SessionResumeRejected( f"event-log gap for session {req.session_id!r}: " f"missing ids {sorted(missing)[:5]}...; replay would be lossy" ) orphaned = replayed_response_request_ids - record.resolvable_request_ids if orphaned: raise SessionResumeRejected( f"replayed responses reference unresolvable requests " f"{sorted(orphaned)}; dependent tool calls would be invalid" ) if record.capability_hash != req.capability_hash: raise SessionResumeRejected( "negotiated capability hash mismatch; replayed events were " "produced under a different initialize context" ) # All three invariants hold; replay is safe. ```