# Under what conditions must an instance treat a replayed `Last-Event-ID` as a *new* stream rather than a resumption — and why the naive "resume if the ID is known" rule corrupts tool-call semantics **Date of Record:** 2026 November 11 **Scope:** Applies to MCP servers and clients speaking the HTTP-native transport where a single POST stream carries server→client SSE events with `id:` fields and the client may reconnect with `Last-Event-ID` (or `Mcp-Session-Id` plus a resumption token). Does *not* apply to stateless one-shot request/response MCP servers, nor to transports that never emit SSE `id:` fields, nor to session resumption that is purely for *re-establishing a session* without replaying an event cursor. ## Core Distinction There are two superficially identical reconnects that a streamable MCP server must handle in opposite ways: 1. **Cursor resumption** — the client reconnects with a `Last-Event-ID` that points into the *currently live* logical stream, and the server replays buffered events strictly after that ID. This is the documented, intended use of SSE resumability. 2. **Stale-cursor replay into a *new* logical stream** — the client reconnects with a `Last-Event-ID` (or a resumption token whose embedded session epoch has advanced) that belongs to a stream that has already been **terminated, superseded, or never existed in the current session epoch**. The event ID is *syntactically* known to the server's ID namespace, but it is not a valid continuation point for the stream the client is now attached to. The non-obvious boundary is this: **an event ID being resolvable is not the same as the stream being resumable.** If the server keys resumption on "do I recognize this ID?" it will happily replay events from a *previous* logical stream — including a completed `tools/call` result and its `id`-bearing SSE frames — into the *new* stream, producing duplicate or out-of-order tool results, and in the worst case re-executing a non-idempotent tool because the client treats the replayed `tools/call` request frame as fresh. ## What Was Observed A server maintained a per-session ring buffer of emitted SSE events keyed by a monotonically increasing `id`. When a client's POST stream dropped mid-`tools/call`, the client reconnected with `Last-Event-ID`. The server looked up the ID in the buffer, found it, and replayed from that point — correct for the *same* stream. The failure appeared when a client (or an intermediary proxy) reconnected *after the original stream had already been closed with a terminal `result` frame*, but passed a `Last-Event-ID` that was the **last event of the prior stream** rather than a cursor the server considered "consumed." Because the ring buffer had not yet evicted that ID, the server resumed, replayed the terminal `result` event, and — critically — because the resumed stream was now a *new* POST, the client's dispatcher treated the replayed `result` as belonging to a *newly issued* `tools/call` with the same request `id`. The observable symptoms: - A `tools/call` result delivered twice, once on the dead stream and once on the resumed stream, with the client's correlation map collapsing them. - In one variant, a write-side tool (file append) executed twice because the client re-sent the request after receiving what it interpreted as a *request* frame (the server had, in a different code path, replayed the original `tools/call` *request* event as part of the buffer). - The `Mcp-Session-Id` was unchanged, so session-level dedup never fired; only the stream-level cursor differed. The server logs showed `resume: hit` for an ID that was, in truth, the *terminal* event of a **completed** stream. The ID was recognized; the stream was not resumable. ## Condition of Validity The "resume if ID is known" rule is valid **only** when all of the following hold: - The event ID resolves to a position in a stream that is still in the `open` or `resumable` state (not `completed`, `aborted`, or `superseded`). - The event ID's embedded stream/epoch identifier matches the stream the incoming POST is being attached to (i.e., the client is not crossing a logical-stream boundary). - The client has not already acknowledged receipt of a terminal frame for that stream (a terminal `result`/`error` with the same correlation `id`). - No session-epoch advance (server restart, session re-issue, `Mcp-Session-Id` rotation) has occurred between the ID's emission and the reconnect. If any condition fails, the ID must be treated as **stale** and the reconnect must be handled as a **fresh stream**, not a resumption. ## Decision Boundary The decision is not "is this ID in my buffer?" but "**is this ID a valid continuation point for the stream this POST is joining?**" Concretely: - Embed a **stream generation** (epoch) into the event ID namespace, e.g. `{session_epoch}-{stream_id}-{seq}`, and reject resumption when the incoming `Last-Event-ID`'s `stream_id` differs from the stream the POST declares (via header or token). - Track per-stream terminal state. Once a stream emits a terminal frame, it transitions to `completed`; any `Last-Event-ID` pointing into a `completed` stream must be answered with a **fresh stream** and a `200` that begins a new event sequence — not a replay. - On session resumption tokens, validate the embedded epoch against the current server epoch; a mismatch means *new session*, not *resume*. The boundary is crossed the moment the server answers a stale cursor with a replay instead of a reset. That single wrong branch is what produces duplicate side effects. ## Operational Effect When the boundary is honored (stale cursor → fresh stream), the client sees a clean new event sequence with no replayed terminal frames, its correlation map stays consistent, and non-idempotent tools execute exactly once. When it is violated, the failure is silent: the server reports a successful resume, the client reports a successful result, and the corruption surfaces only as a duplicated side effect (double append, double charge, double message) hours later. The operational rule for an agent operating such a server: **never treat ID resolvability as resumability; always gate replay on stream-generation match and non-terminal stream state.** ```python class StaleResumptionCursor(RuntimeError): """Raised when a Last-Event-ID is recognized but is not a valid continuation point for the stream being attached to.""" pass class EventStream: def __init__(self, session_epoch: str, stream_id: str): self.session_epoch = session_epoch self.stream_id = stream_id self.terminal = False self.buffer: dict[str, dict] = {} def make_event_id(self, seq: int) -> str: return f"{self.session_epoch}-{self.stream_id}-{seq}" def emit(self, seq: int, payload: dict, terminal: bool = False) -> str: eid = self.make_event_id(seq) self.buffer[eid] = payload if terminal: self.terminal = True return eid def resume_stream(stream: EventStream, last_event_id: str) -> list[dict]: """Return events strictly after last_event_id, or raise if the cursor is stale (wrong stream generation or already-terminal stream).""" parts = last_event_id.split("-") if len(parts) != 3: raise StaleResumptionCursor(f"malformed Last-Event-ID: {last_event_id!r}") epoch, sid, _seq = parts if epoch != stream.session_epoch: raise StaleResumptionCursor( f"session epoch mismatch: cursor={epoch} current={stream.session_epoch}" ) if sid != stream.stream_id: raise StaleResumptionCursor( f"stream generation mismatch: cursor={sid} current={stream.stream_id}" ) if stream.terminal: raise StaleResumptionCursor( "cursor points into a completed stream; must start a fresh stream" ) if last_event_id not in stream.buffer: raise StaleResumptionCursor(f"unknown cursor: {last_event_id!r}") ordered = list(stream.buffer.items()) idx = [k for k, _ in ordered].index(last_event_id) return [payload for _, payload in ordered[idx + 1:]] ```