# When must an MCP server reject a client's `Last-Event-ID` resume request rather than honor it? **Date of Record:** 2026 November 6 **Scope:** Applies to Model Context Protocol servers that emit Server-Sent Events for streaming tool results and support client reconnection via the `Last-Event-ID` header. Does not apply to stateless request/response MCP transports or to servers that emit no event identifiers. ## Core Distinction The `Last-Event-ID` header is a *claim*, not a *fact*. A client reconnecting after a dropped connection presents the ID of the last event it successfully received. The server's job is not to trust that claim and replay everything after it — it is to decide whether the claim is *resumable* against the server's current event history. Two distinct failures hide behind the same header: 1. **Gap resumability** — the requested ID exists in history, and all subsequent events are still buffered. Replay is safe. 2. **Divergence** — the requested ID exists, but the stream's *generation* has changed since that event was emitted (server restarted, worker reassigned, tool invocation was retried). Replaying from that ID would splice events from two different logical executions into one client-visible sequence. The non-obvious boundary: **ID existence is not sufficient for resumability.** An event ID can be perfectly valid and still be unsafe to resume from, because the ID is scoped to a stream generation, not just to a sequence position. ## What Was Observed A streaming MCP server assigned monotonically increasing integer event IDs (`1, 2, 3, ...`) per connection and buffered the last N events per connection. Clients reconnected with `Last-Event-ID: 47`. The server looked up index 47 in the buffer, found it, and replayed 48 onward. After a mid-stream worker failover, the replacement worker started its own counter at `1`. A client that had received events `1..47` from worker A reconnected during worker B's tenure and presented `Last-Event-ID: 47`. Worker B's buffer had no event 47 yet, so the server fell through to a "ID not found → replay from beginning" path and re-emitted events `1..12` — which the client appended to its already-complete `1..47` sequence. The client's tool-result accumulator saw duplicated partial JSON fragments and produced a corrupted result. The corruption was silent: no error, no gap, just a plausible-looking but wrong stream. A second variant: same worker, but the tool invocation had been *retried* after a transient upstream error. The retry reused the same connection ID and restarted the counter, so `Last-Event-ID: 47` matched a *different* execution's event 47. The client resumed into a stream whose prior events it had never seen. ## Condition of Validity Honoring a `Last-Event-ID` resume is valid only when **all** of the following hold: - The ID resolves to an entry in the server's retained buffer (not "not found"). - The buffer entry's **stream generation token** equals the generation the client is implicitly resuming against. The server must encode generation into the ID or maintain a per-connection generation map; a bare integer fails this. - The events between the requested ID and the buffer tail are **contiguous and complete** — no eviction hole was created by buffer overflow between the requested ID and the tail. - The stream is not terminal: if the tool invocation already completed and its terminal event was evicted, there is nothing meaningful to resume into. If the requested ID is *older than the buffer head*, the gap cannot be bridged by replay; the client must be told to re-request from scratch. If the ID is *newer than the buffer tail*, the client is claiming an event the server never emitted — a protocol violation, not a resume. ## Decision Boundary The decision is a three-way branch, not a boolean: - **Resume** — ID in buffer, generation matches, no eviction hole, stream not terminal. - **Reject-and-reset** — ID missing, generation mismatch, eviction hole, or terminal stream. The server must respond with a fresh SSE stream carrying a new generation token and instruct the client to discard its partial state. Rejecting means *refusing to splice*, not refusing to serve. - **Reject-as-invalid** — ID is syntactically well-formed but claims an event beyond the tail, or the generation token is unrecognized. This is a client bug or a spoofed resume; close with an error event rather than silently resetting, because silent reset masks a client that is mis-tracking its own state. The common mistake is collapsing all three into "resume from beginning," which is the branch that produces silent corruption. ## Operational Effect Encoding generation into the event ID (e.g., `gen-workerB:47`) makes the mismatch detectable without a side table, and makes the "ID not found" and "ID found but wrong generation" cases distinguishable — they demand different client responses. Buffers must retain a *terminal marker* per generation so that a resume against a completed stream is rejected rather than replayed into an empty tail. Clients must treat reject-and-reset as a signal to discard accumulated partial output, not to merge. The cost of getting this wrong is not a visible failure; it is a stream that looks complete and is wrong, which is the worst failure mode for an agent consuming tool results. ```python class NonResumableStreamError(RuntimeError): """Raised when a Last-Event-ID resume cannot be safely honored.""" class StreamGeneration: def __init__(self, token: str): self.token = token self.events: list[tuple[str, str]] = [] # (event_id, payload) self.terminal = False def append(self, seq: int, payload: str) -> str: event_id = f"{self.token}:{seq}" self.events.append((event_id, payload)) return event_id def mark_terminal(self) -> None: self.terminal = True def resume_stream(gen: StreamGeneration, last_event_id: str, buffer_head_seq: int) -> list[str]: if gen.terminal: raise NonResumableStreamError( f"stream generation {gen.token} is terminal; client must reset" ) token, _, seq_str = last_event_id.partition(":") if token != gen.token: raise NonResumableStreamError( f"generation mismatch: client claims {token!r}, server is {gen.token!r}" ) try: seq = int(seq_str) except ValueError: raise NonResumableStreamError(f"malformed event id {last_event_id!r}") if seq > len(gen.events): raise NonResumableStreamError( f"event id {last_event_id!r} is beyond tail; client state is inconsistent" ) if seq < buffer_head_seq: raise NonResumableStreamError( f"event id {last_event_id!r} evicted (head={buffer_head_seq}); gap cannot be bridged" ) return [payload for _, payload in gen.events[seq:]] ```