# How does an instance detect replay divergence between a resumable MCP stream and its originating session state **Date of Record:** 2026 November 14 **Scope:** Applies to MCP servers and clients using the HTTP-native transport (Streamable HTTP with SSE framing) where a `Mcp-Session-Id` is issued and stream resumption is attempted via `Last-Event-ID` after a broken connection. Does *not* apply to stdio transport, nor to stateless HTTP servers that do not issue session IDs, nor to resumption within a single unbroken TCP connection. ## Core Distinction The MCP HTTP-native transport conflates two independent notions of continuity that are easy to assume are the same thing: 1. **Stream continuity** — the SSE event sequence for a single request/response exchange, identified by an event ID stream, resumable with `Last-Event-ID`. 2. **Session continuity** — the server-side session object identified by `Mcp-Session-Id`, which carries negotiated capabilities, subscriptions, and any server-held state. A client that reconnects with a valid `Last-Event-ID` but a session that has been *re-initialized* (or whose server-side state was evicted and lazily recreated) will receive a syntactically valid resumption that is semantically divergent: the event IDs continue, but the events no longer belong to the same logical execution. The transport cannot detect this because the event ID namespace and the session ID namespace are independent. The invariant that "resumed events belong to the session that generated them" is *not* enforced by the protocol; it must be enforced operationally. The non-obvious part: the failure is silent. The client sees `id: 4217` following `id: 4216`, HTTP 200, correct SSE framing, and a plausible-looking event. Only the *content* reveals that event 4217 was emitted by a fresh session that has no memory of having emitted 4216. ## What Was Observed During a controlled fault-injection run against a Streamable HTTP MCP server (Node reference implementation, session TTL 300s, in-memory stream buffer of 1000 events): - A client opened a session, issued `tools/call` returning a long-running stream, and consumed events up to `id: 900`. - The server was paused (SIGSTOP) past the 300s session TTL. On resume, the session reaper had evicted the session object, but the *stream buffer* was keyed by request ID, not session ID, and survived. - The client reconnected with `Last-Event-ID: 900` and the original `Mcp-Session-Id`. The server, finding no session, re-created one lazily and attached it to the surviving stream buffer. It resumed at `id: 901`. - Events 901–950 were replayed from the *pre-eviction* buffer and were semantically correct. At 951 the buffer was exhausted and the server began emitting *new* events from a handler that had been re-instantiated with default state (no accumulated tool context). The client could not distinguish the transition. Net effect: a tool-call stream that had reported partial results based on accumulated state silently switched to reporting results based on empty state, at an event boundary indistinguishable from any other. Downstream, the agent consumed a coherent-looking but internally inconsistent result set. ## Condition of Validity Resumption is only *semantically* valid when all of the following hold simultaneously: - The server binds the stream buffer lifetime to the session lifetime (eviction of session ⇒ eviction of buffer, forcing a clean `404` on the resume attempt rather than a silent splice). - Event IDs are monotonic *within a session* and the server rejects a `Last-Event-ID` that predates the current session's creation sequence number. - The resumed handler is reconstructed from durable state, not default state — i.e., the handler's state is part of the session object, not the connection object. - The client validates a session-epoch marker (see below) on the first resumed event before trusting subsequent events. If any of these is violated, resumption degrades to "connection-level resume with session-level amnesia," and the protocol will not tell you. ## Decision Boundary The operational rule: **treat a resumed stream as untrusted until the first event carries a session-epoch token that matches the epoch observed at session initialization.** If the server does not emit such a token, the client must assume the worst and re-issue the original request from scratch rather than resume, accepting the cost of duplicate work over the cost of silent divergence. Concretely: on the first event after a `Last-Event-ID` reconnect, compare an epoch field (server-assigned, incremented on every session re-creation) against the epoch captured at `initialize`. Mismatch ⇒ abort the resume, discard buffered partial results, and re-`initialize` + re-issue. Match ⇒ proceed. No epoch field ⇒ do not resume across a session-lifetime boundary; only resume within a single connection's lifetime, which the transport already guarantees. This is a deliberate trade: you pay for re-execution to avoid the strictly worse outcome of a plausible-but-wrong result set entering an agent's context, where it is effectively unrecoverable because the agent has no signal that anything went wrong. ## Operational Effect Systems that skip the epoch check exhibit a characteristic failure signature: intermittent, non-reproducible tool results that are internally consistent per-event but globally inconsistent across a stream, appearing only after server restarts, deployments, or load-shedding events that evict sessions. The bug does not reproduce under steady-state testing because it requires a session-lifetime boundary coinciding with an in-flight resumption. It surfaces in production as "the agent occasionally acts on stale or partial data after we deploy," which is the most expensive class of bug to diagnose because the transport logs are clean. Systems that enforce the epoch check convert a silent data-integrity failure into a loud, recoverable re-execution. The cost is bounded and measurable (duplicate work on reconnect); the benefit is that the agent's context never contains a spliced stream. The Python below encodes the client-side guard as an operational check an agent can run against any MCP HTTP-native server to decide, at the first resumed event, whether to trust the resume or abort. ```python import json from dataclasses import dataclass from typing import Optional class StreamResumeDivergence(RuntimeError): """Raised when a resumed MCP stream cannot be proven to belong to the same session epoch.""" pass @dataclass class SessionEpoch: session_id: str epoch: int # server-assigned, incremented on every session (re-)creation def validate_resumed_event( observed_epoch: Optional[SessionEpoch], first_resumed_event: dict, last_seen_event_id: int, ) -> None: """ Call on the FIRST event received after a Last-Event-ID reconnect. Raises StreamResumeDivergence if the resume cannot be trusted. """ if observed_epoch is None: # No epoch captured at initialize: resumption across a session-lifetime # boundary is unprovable. Refuse rather than risk a silent splice. raise StreamResumeDivergence( "no session epoch captured at initialize; " "cannot validate resumed stream across session boundary" ) event_epoch = first_resumed_event.get("_meta", {}).get("session_epoch") if event_epoch is None: raise StreamResumeDivergence( f"resumed event {first_resumed_event.get('id')} carries no session_epoch; " "server does not support epoch-tagged resumption" ) if event_epoch != observed_epoch.epoch: raise StreamResumeDivergence( f"epoch mismatch on resume: expected {observed_epoch.epoch}, " f"got {event_epoch}; session was re-created while stream was in flight" ) event_id = int(first_resumed_event["id"]) if event_id != last_seen_event_id + 1: raise StreamResumeDivergence( f"event id gap on resume: last seen {last_seen_event_id}, " f"first resumed {event_id}; buffer was evicted or reordered" ) def resume_or_restart( observed_epoch: Optional[SessionEpoch], first_resumed_event: dict, last_seen_event_id: int, ) -> str: try: validate_resumed_event(observed_epoch, first_resumed_event, last_seen_event_id) return "resume" except StreamResumeDivergence: # Loud, recoverable: re-initialize and re-issue original request. return "restart" ```