# Where must an instance Reconcile the SSE Event-ID Cursor Before Resuming an MCP Streamable-HTTP Session **Date of Record:** 2026 November 12 **Scope:** Applies to MCP servers and clients using the Streamable HTTP transport (JSON-RPC over POST with optional Server-Sent Events upgrade and `Last-Event-ID` resumption) for remote tool invocation. Does not apply to stdio transports, nor to plain request/response HTTP where no event cursor is negotiated. ## Core Distinction There are two distinct notions of "session continuity" in Streamable HTTP, and they diverge precisely at the resumption boundary: 1. **Session continuity** — the `Mcp-Session-Id` header identifies a logical conversation. It is minted at `initialize`, echoed by the client on every subsequent request, and is the server's key for per-session state (tool registry snapshot, negotiated capabilities, auth context). 2. **Stream continuity** — the SSE `id:` field is a per-stream monotonic cursor. On reconnect, the client sends `Last-Event-ID` and the server is expected to replay events strictly after that cursor. The non-obvious invariant: **a valid session ID does not imply a valid event cursor, and a valid event cursor does not imply a resumable session.** A resumption attempt is only semantically safe when *both* are valid *and* the cursor belongs to the same session generation that the server currently holds. The failure mode is not a clean 404 — it is a *silent partial replay* or an *event-ID gap* that the client treats as authoritative. ## What Was Observed During a long-running remote tool invocation (a streaming `tools/call` emitting progress notifications), the network path was interrupted mid-stream. On reconnect the client sent: - `Mcp-Session-Id: ` (still valid on the server) - `Last-Event-ID: 41` (the last event it had durably processed) The server had, in the interim, garbage-collected the per-stream event buffer for session `` under a TTL policy but retained the session object itself (session TTL > stream buffer TTL). The server therefore had no record of event `41`. Two behaviors were observed across deployments: - **Behavior A (silent reset):** The server accepted the request, opened a fresh SSE stream, and began emitting new events with IDs restarting from `1`. The client, holding `Last-Event-ID: 41`, discarded events `1..41` as "already seen" and processed only from `42` onward — losing an entire window of tool progress and, in one case, the terminal result event. The client never observed an error. - **Behavior B (cursor reuse after session generation bump):** The session had been re-initialized (e.g., server restart with session persistence keyed only by ID). The new generation's event counter started at `1` again, but the client's `Last-Event-ID: 41` was honored against the *new* generation's numbering, again producing a gap. In both cases the JSON-RPC layer reported success: the POST returned `200`, the stream was well-formed, and no protocol-level error surfaced. The corruption was purely at the semantic layer — a *missing* event that the transport considered *delivered*. ## Condition of Validity Resumption via `Last-Event-ID` is valid if and only if all of the following hold simultaneously: 1. The `Mcp-Session-Id` resolves to a live session object on the server. 2. The event buffer associated with the *current stream generation* for that session still contains events with IDs strictly greater than `Last-Event-ID`. 3. The event-ID namespace is monotonic **across the session's lifetime**, not per-stream-instance. If the server resets numbering on reconnect, the cursor is meaningless. 4. The client's `Last-Event-ID` was persisted *after* the corresponding event was fully processed (including any side effects), not merely after receipt. Condition 3 is the one most frequently violated. Many implementations treat the SSE `id` as a per-connection counter because that is the natural reading of the SSE spec, which does not mandate cross-connection monotonicity. MCP's resumable semantics require it, but the transport does not enforce it. ## Decision Boundary The client must not blindly send `Last-Event-ID` on reconnect. The decision procedure at the resumption boundary is: - **If the session is known-dead (server returned `404` on a prior probe, or session TTL is known to be shorter than the disconnect window):** do not resume. Re-`initialize` and re-issue the tool call idempotently (requires the tool to be declared idempotent or to accept a client-generated request key). - **If the session is live but the stream buffer TTL is uncertain:** the client must *probe* by opening the stream with `Last-Event-ID` and inspecting the first replayed event ID. If the first event ID is `<= Last-Event-ID + 1` and not exactly `Last-Event-ID + 1`, the cursor is stale or the namespace reset — abort the stream and fall back to full re-initialization. - **If the first event ID is exactly `Last-Event-ID + 1`:** replay is contiguous; proceed. Any gap (first ID > `Last-Event-ID + 1`) is unrecoverable within this session and must trigger the same fallback. The invariant the client enforces is: *contiguity of the event-ID sequence is the only proof of resumption validity.* A server that cannot guarantee contiguity must advertise that it does not support resumption (or must return an explicit `400` on an unserviceable `Last-Event-ID`), rather than silently restarting numbering. ## Operational Effect The operational consequence is that **resumable MCP streaming is a two-party invariant, and the transport cannot enforce it alone.** Servers that reset event numbering on reconnect convert a recoverable network blip into silent data loss at the tool-result layer — the worst class of failure, because it is invisible to both the JSON-RPC error channel and the SSE error channel. Clients that trust `Last-Event-ID` without contiguity checking inherit that loss. The mitigation that held in practice was to treat the event-ID cursor as a *capability negotiated at `initialize`*: the server declares whether its event IDs are session-lifetime monotonic, and the client only sends `Last-Event-ID` if that capability was granted. Absent the capability, the client treats every disconnect as a session termination and re-initializes with an idempotency key. This trades a small amount of replay cost for the elimination of the silent-gap failure mode. The code below encodes the client-side boundary check as an explicit guard, so that a non-contiguous replay is a hard, typed error rather than a silent skip. ```python from dataclasses import dataclass from typing import Optional class ResumptionCursorError(RuntimeError): """Raised when an SSE replay cannot be proven contiguous with the client cursor.""" pass @dataclass class ResumeState: session_id: str last_event_id: Optional[int] session_lifetime_monotonic: bool def validate_resumption(state: ResumeState, first_replayed_id: Optional[int]) -> None: """ Enforce the contiguity invariant before consuming a resumed MCP SSE stream. - If the server did not negotiate session-lifetime monotonic event IDs, we may not trust Last-Event-ID at all. - If we sent a cursor, the first replayed event MUST be exactly cursor+1. Anything else (None, a reset to 1, or a forward gap) is unrecoverable within this session and must force full re-initialization. """ if not state.session_lifetime_monotonic: raise ResumptionCursorError( f"session {state.session_id} did not negotiate monotonic event IDs; " "refusing to resume on Last-Event-ID" ) if state.last_event_id is None: # Fresh stream, no cursor sent; nothing to prove. return if first_replayed_id is None: raise ResumptionCursorError( f"session {state.session_id}: server opened stream with no replayed " f"event for cursor {state.last_event_id}; buffer likely expired" ) expected = state.last_event_id + 1 if first_replayed_id != expected: raise ResumptionCursorError( f"session {state.session_id}: non-contiguous replay; expected " f"event {expected}, got {first_replayed_id}. Event-ID namespace " "likely reset or gap is unrecoverable; fall back to re-initialize." ) ```