# What happens when an instance resumes an MCP HTTP-native transport stream against a migrated session **Date of Record:** 2026 November 13 **Scope:** Applies to MCP servers exposing the HTTP-native transport (Streamable HTTP with `Mcp-Session-Id` and resumable SSE via `Last-Event-ID`), where a client may reconnect to a *different* server instance than the one that produced the stream. Does not apply to stdio transport, nor to single-instance deployments where session affinity is guaranteed by the load balancer. ## Core Distinction The MCP HTTP-native transport defines two *independent* pieces of resumption state: the **session** (identified by `Mcp-Session-Id`, which scopes server-side capability negotiation and any in-memory context) and the **stream cursor** (identified by the `Last-Event-ID` header, which scopes replay of an individual SSE event stream). The specification treats these as orthogonal. Operational reality does not: the event ID is only meaningful to the process that emitted it. A client that resumes a stream by presenting a valid `Last-Event-ID` to a *different instance* — one that has migrated or rebalanced the session — will receive a syntactically correct `200 OK` SSE response that is semantically empty or, worse, silently divergent. The failure is not a connection error. It is a *successful* response that violates the replay invariant the client depends on. ## What Was Observed During a rolling deploy of a three-replica MCP server behind a round-robin ingress, a long-lived tool-streaming session was interrupted mid-stream. The client correctly reconnected with `Mcp-Session-Id: ` and `Last-Event-ID: 4821`. The new replica: 1. Accepted the session ID because the session store was shared (Redis-backed), so the session *validated*. 2. Had no local record of event `4821`, because event buffers were process-local ring buffers, not shared. 3. Made a defensible-but-wrong choice: it treated an unknown `Last-Event-ID` as "start fresh" and opened a new stream from event ID `0` of the *migrated* session's current cursor. The client received events it had already processed (duplicate tool-result deltas) interleaved with a gap where events `4822–4900` had been emitted on the old replica and never replicated. The SSE framing was valid throughout. No HTTP status signaled trouble. The duplicate deltas caused a downstream state machine to apply a tool result twice; the gap caused a subsequent `sampling/createMessage` continuation to reference a stale message index. The incident surfaced as a *data correctness* bug three layers away from the transport, not as a transport error. ## Condition of Validity Stream resumption via `Last-Event-ID` is valid **only if** the following holds for the connection receiving the resume request: - The event-ID namespace is scoped to a single producer instance, **and** the client is guaranteed to reconnect to that same instance (sticky affinity), **or** - The event buffer backing the ID namespace is *shared and durable* across all instances that may accept the session, with a monotonic, globally consistent event ID (e.g., a per-session log with a total order, not per-process counters). If neither holds, the resume request is *unanswerable*: the correct response is not to replay from zero, and not to replay from the current cursor, but to fail the resume explicitly so the client can re-establish the session from scratch (re-negotiate capabilities, re-issue in-flight requests). The spec does not mandate this failure mode; it must be chosen deliberately. ## Decision Boundary The boundary is crossed at the moment a session is *migratable* but its stream cursor is *not*. Concretely: - **Session state shared, stream buffer local** → migration is unsafe for in-flight streams. This is the common misconfiguration: teams share the session map (cheap, small) but not the event log (large, high-churn), and assume resumption works because the session validates. - **Session state local, stream buffer local** → safe *if* affinity is enforced; the ingress must pin `Mcp-Session-Id` to an instance. Resumption to a wrong instance will fail session validation first, which is a clean, detectable error. - **Session state shared, stream buffer shared with global ordering** → safe; resumption from any instance is correct. The dangerous quadrant is the first. It passes every liveness check (session validates, SSE opens, bytes flow) while failing the one invariant that matters (exactly-once, gap-free replay of the event stream). ## Operational Effect Two mitigations are load-bearing. First, on receiving a `Last-Event-ID` the instance does not recognize, the server must return an explicit, distinguishable failure — not a fresh stream. A `409 Conflict` or a structured SSE `error` event with a machine-readable code (e.g., `stream_cursor_unrecoverable`) lets the client distinguish "resume succeeded" from "resume silently degraded." Second, the session record must carry a **producer-instance identity** (an epoch or instance UUID) that the resuming instance compares against its own; a mismatch means the cursor is foreign and must be rejected regardless of buffer contents. Without these, the failure is invisible at the transport layer and surfaces only as duplicated or missing side effects downstream. The following guard encodes the boundary: it rejects a resume whose cursor belongs to a foreign producer epoch, forcing the client to re-establish the session rather than accept a silently divergent stream. ```python class StreamResumeInvariantError(RuntimeError): """Raised when a resume request cannot satisfy the gap-free replay invariant.""" pass class EventCursor: def __init__(self, producer_epoch: str, last_event_id: int): self.producer_epoch = producer_epoch self.last_event_id = last_event_id class SessionRecord: def __init__(self, session_id: str, producer_epoch: str, buffer_epochs: set[str]): self.session_id = session_id self.producer_epoch = producer_epoch # instance that owns the live stream self.buffer_epochs = buffer_epochs # epochs whose events we can actually replay def resume_stream( session: SessionRecord, cursor: EventCursor, local_instance_epoch: str, shared_event_log: bool, ) -> int: """ Decide whether a Last-Event-ID resume can be honored. Returns the event ID to replay from. Raises if the invariant cannot hold. """ if cursor.producer_epoch not in session.buffer_epochs: # We have no events from the producer that emitted this cursor. # Replaying from 0 or from the current cursor both violate the invariant. raise StreamResumeInvariantError( f"session {session.session_id}: cursor epoch {cursor.producer_epoch!r} " f"not in replayable set {sorted(session.buffer_epochs)}; " f"resume is unanswerable, client must re-establish session" ) if cursor.producer_epoch != local_instance_epoch and not shared_event_log: # Foreign producer, local-only buffer: we cannot guarantee gap-free replay. raise StreamResumeInvariantError( f"session {session.session_id}: cursor owned by {cursor.producer_epoch!r}, " f"local instance is {local_instance_epoch!r} with no shared event log" ) # Safe: either same producer with local buffer, or shared log with global order. return cursor.last_event_id + 1 def handle_resume(session: SessionRecord, cursor: EventCursor, local_instance_epoch: str, shared_event_log: bool) -> str: try: next_id = resume_stream(session, cursor, local_instance_epoch, shared_event_log) except StreamResumeInvariantError as exc: # Emit an explicit, distinguishable failure — never a silent fresh stream. return f"event: error\ndata: {{\"code\":\"stream_cursor_unrecoverable\",\"detail\":{exc!r}}}\n\n" return f"event: resume\ndata: {{\"replay_from\":{next_id}}}\n\n" ```