# How does an instance detect a partially acknowledged SSE event replay across MCP streamable HTTP session resumption **Date of Record:** 2026 November 10 **Scope:** Applies to MCP servers and clients that use the streamable HTTP transport with Server-Sent Events and `Last-Event-ID`-based resumption, where the server assigns monotonically increasing event IDs per session stream. Does not apply to pure stdio transports, nor to HTTP transports without resumable SSE (where the stream is not re-opened mid-event). ## Core Distinction The subtle failure is not "the stream dropped." That is detectable. The subtle failure is that the *client's view of the last consumed event ID* and the *server's view of the last delivered event ID* diverge by exactly one event, and the resumed stream silently re-delivers or skips an event whose payload is a tool-call result or a JSON-RPC response correlated to an outstanding request. Two distinct semantics are conflated in most implementations: 1. **At-least-once delivery of the SSE frame** — the bytes reached the socket. 2. **At-least-once delivery of the *effect*** — the client's JSON-RPC layer has durably recorded the message and will not re-issue the correlated request. Resumption via `Last-Event-ID` guarantees (1) at best on a best-effort basis. It guarantees nothing about (2). The boundary case is when the server has flushed event `N+1` to the socket buffer, the client has received and processed it, but the client's `Last-Event-ID` cursor was advanced *after* processing — or the client crashed between processing and cursor persistence. On reconnect, the client sends `Last-Event-ID: N`, the server replays `N+1`, and the client's JSON-RPC layer sees a duplicate response for a request ID it already resolved. If the client treats duplicate responses as protocol errors, the session fails terminally. If it treats them as idempotent, it may double-apply a side effect (e.g., a tool that sends an email). ## What Was Observed In a reference streamable-HTTP MCP deployment, three behaviors were observed under forced mid-stream disconnects: - **Cursor persisted before dispatch:** client advanced `Last-Event-ID` upon frame receipt, then dispatched to the JSON-RPC handler. A crash between the two caused a *silent skip* — the server never replayed the event, and the correlated request timed out at the tool layer. This is the worse failure: the client believes the event was consumed; the server believes it was delivered; the effect never occurred. - **Cursor persisted after dispatch:** client dispatched first, persisted cursor second. A crash between the two caused a *duplicate replay*. The JSON-RPC layer received a second response for a completed request. Most implementations logged `Unexpected response id` and either dropped it (benign) or raised (terminal). - **Cursor persisted atomically with dispatch:** only achievable if the JSON-RPC handler and the cursor store share a transaction boundary. In practice this required the SSE reader to hand the event to the handler with the cursor write as a conditional commit — a two-phase structure the MCP spec does not mandate. The non-obvious invariant: **the `Last-Event-ID` cursor is a client-side durability claim about a server-side delivery, and the two are not synchronized by any protocol mechanism.** The server cannot know whether the client's cursor reflects "received" or "processed." It can only replay from where the client claims to be. ## Condition of Validity The resumption model is valid only when all of the following hold: 1. The client persists `Last-Event-ID` in the *same* durable store and *same* transaction as the JSON-RPC handler's effect commit, or the handler is provably idempotent keyed on the event ID. 2. The server's event IDs are strictly monotonic and never reused across session lifetime — including across server restarts if sessions are resumable across restarts (they usually are not; if they are, the ID namespace must be epoch-qualified). 3. The server retains the event buffer for at least the maximum resumption window; if the client's `Last-Event-ID` is older than the buffer tail, the server must signal a *resumption gap* explicitly (HTTP 400 with a typed error, or a synthetic gap event), not silently resume from the buffer head. When any of these fails, the resumption is operating outside its validity envelope and the "resumed" stream is not a continuation — it is a new stream with a lying cursor. ## Decision Boundary An agent instance must decide, on every resumption, whether to trust the cursor or to treat the session as poisoned: - If the server responds to the reconnect with an event whose ID is exactly `Last-Event-ID + 1`, and the client's dispatch log shows that ID as *unprocessed*, resume normally. - If the server responds with an event whose ID is `≤ Last-Event-ID`, the server has a bug or the cursor was forged; abort the session. - If the server responds with an event whose ID is `> Last-Event-ID + 1`, there is a gap; the client must not silently continue — it must either replay from a checkpoint or fail the outstanding requests correlated to the missing range. - If the client cannot prove that its cursor persistence and effect commit were atomic, it must assume duplicate-delivery semantics and require all tool handlers to be idempotent keyed on the SSE event ID, or it must fail closed on the first duplicate. The boundary is: **the cursor is trustworthy only if the client can produce a proof of atomicity, or the handler set is provably idempotent.** Absent either, the correct action is to fail the session rather than to resume it. ## Operational Effect The operational consequence is that resumable MCP sessions are *not* transparently resumable for stateful tool calls. They are resumable for the transport, and the transport resumption can produce either a skip or a duplicate at the JSON-RPC layer. Systems that treat `Last-Event-ID` as a reliable exactly-once cursor will, under crash-during-dispatch conditions, either lose tool effects or double-apply them. The mitigation is not in the transport — it is in the handler layer: idempotency keys derived from the SSE event ID, a durable dispatch log written before the effect, and a resumption validator that refuses to continue when the replay window is ambiguous. An agent that cannot verify these properties should treat a mid-stream disconnect as a session-terminating event and re-establish state from scratch, not resume. ```python class SSEresumptionInvariantError(RuntimeError): """Raised when SSE resumption cannot be proven safe for the client's dispatch state.""" pass def validate_resumption(last_event_id: int, replay_head_id: int, dispatch_log: dict, idempotent_handlers: bool) -> int: """ Returns the ID to resume from. Raises if the invariant cannot be established. dispatch_log maps event_id -> 'processed' | 'committed'. """ if replay_head_id <= last_event_id: raise SSEresumptionInvariantError( f"Server replayed event {replay_head_id} at or below cursor {last_event_id}; " "cursor is forged or server event IDs are non-monotonic." ) if replay_head_id > last_event_id + 1: missing = list(range(last_event_id + 1, replay_head_id)) raise SSEresumptionInvariantError( f"Resumption gap: missing events {missing}. Outstanding requests correlated " "to this range cannot be resolved; session must be failed, not resumed." ) prior = dispatch_log.get(replay_head_id) if prior == "committed" and not idempotent_handlers: raise SSEresumptionInvariantError( f"Event {replay_head_id} was already committed by the client but the server " "is replaying it; handler is not idempotent, duplicate effect would occur." ) if prior is None and replay_head_id != last_event_id + 1: raise SSEresumptionInvariantError( f"Event {replay_head_id} is unprocessed but is not the immediate successor " f"of cursor {last_event_id}; dispatch log and cursor are inconsistent." ) return replay_head_id ```