# When must an instance treat an MCP `Last-Event-ID` resume as a fresh session rather than a continuation **Date of Record:** 2026 November 14 **Scope:** Applies to HTTP-native MCP servers that expose SSE-based streaming (the `GET` stream endpoint) and accept `Last-Event-ID` on reconnection, where the server also maintains per-session state keyed by an `Mcp-Session-Id`. Does not apply to stateless streamable-HTTP transports or to stdio transports, where session identity is implicit in the process lifetime. ## Core Distinction The `Last-Event-ID` header and the `Mcp-Session-Id` header are orthogonal identifiers that the protocol does not require to be consistent with each other. `Last-Event-ID` is a *stream cursor*: it says "resume this event sequence from position N." `Mcp-Session-Id` is a *state handle*: it says "I am the same logical client that owns this server-side session." A resume request carries both. The boundary failure occurs when the server honors the `Last-Event-ID` cursor (replaying buffered events) while the `Mcp-Session-Id` presented no longer maps to a live session — or maps to a *different* session than the one that produced those events. The non-obvious invariant: **event IDs must be scoped to a session, not to a stream endpoint.** If event IDs are generated from a global monotonic counter or a per-endpoint sequence, a `Last-Event-ID` from a dead session can be replayed against a new session's event buffer, delivering events that were never addressed to the new session's client. The client then processes tool results, progress notifications, or sampling requests that belong to a prior, possibly different, principal. ## What Was Observed A server restarted its process pool, invalidating all in-memory session state. A client reconnected with its cached `Mcp-Session-Id` and the last `Last-Event-ID` it had received (`evt-4471`). The server's session lookup failed, so it minted a fresh session and returned a new `Mcp-Session-Id` in the response headers — but the SSE handler, written before the session layer, keyed its replay buffer by the numeric suffix of the event ID alone, and the buffer had been repopulated by an unrelated client whose stream had reached `evt-4471` first. The reconnecting client received three `notifications/progress` events and one `tools/call` result that belonged to a different tenant's request. No error was raised; the client's request IDs did not match, so the mismatched result was silently dropped — but the progress notifications were applied, corrupting the client's belief about the state of its own in-flight operation. The failure is silent because both headers are individually valid. The server has no protocol-level signal that the pairing is wrong. ## Condition of Validity Treating a resume as a continuation is valid only when *all* of the following hold: 1. The presented `Mcp-Session-Id` resolves to a live server-side session. 2. The `Last-Event-ID` was issued by *that* session (i.e., event IDs are namespaced by session, e.g. `{session_id}:{seq}` or a per-session random prefix). 3. The replay buffer for that session still contains the event at or after the cursor, or the server can prove the cursor is within the retained window and no events were lost. 4. The event ID has not been reused across a session reincarnation (restart, expiry, or explicit re-init). If any condition fails, the request is not a continuation; it is a *new subscription that happens to carry a stale cursor*. The correct response is to reject the cursor — either by ignoring `Last-Event-ID` and starting the stream fresh (with an explicit `event: reset` or an out-of-band signal), or by returning a 404/409 that forces the client to re-initialize. Silent replay is never correct. ## Decision Boundary The decision hinges on whether the cursor is *meaningful* to the resolved session, not on whether the session exists. Concretely: - **Session live + cursor in-window + cursor namespaced to session** → resume, replay from cursor. - **Session live + cursor outside retained window** → do not replay; start fresh and signal a gap, because the client has missed events it cannot reconstruct. - **Session dead + cursor present** → the session is new; the cursor is from a prior incarnation. Discard the cursor, start fresh, and surface a session-reset notification so the client re-issues in-flight requests. Replaying is a cross-session data leak. - **Session live + cursor belongs to a different session** → reject (409). This is the tenant-isolation boundary. The subtle case is session expiry with a *short* gap: the server may be tempted to "helpfully" replay from the buffer because the events are still physically present. This is the exact failure above. Physical presence in a buffer is not authorization to deliver. ## Operational Effect Enforcing the boundary converts a silent cross-session contamination into an explicit, recoverable reset. The client sees a session-reset event, discards its optimistic state for in-flight requests, and re-issues them against the new session. The cost is a small number of redundant retries on restart; the benefit is that no client ever observes another client's events. For agents driving MCP servers, the operational rule is: **on any resume, verify the session identity before trusting the cursor; if the server does not expose session-scoped event IDs, assume the cursor is untrustworthy and force a fresh subscription.** ```python class SessionCursorMismatch(RuntimeError): """Raised when a Last-Event-ID cursor cannot be attributed to the resolved session.""" pass def resolve_resume( presented_session_id: str | None, last_event_id: str | None, live_sessions: dict[str, "Session"], retained_window: int, ) -> tuple["Session", int | None]: """ Decide whether a reconnect is a continuation or a fresh subscription. Returns (session, replay_from_seq_or_None). """ if presented_session_id is None: # No session handle: cannot be a continuation. return new_session(), None session = live_sessions.get(presented_session_id) if session is None: # Session is dead. The cursor is from a prior incarnation and must not # be replayed against a freshly minted session. return new_session(), None if last_event_id is None: return session, None seq = parse_event_id(last_event_id) if seq is None: raise SessionCursorMismatch( f"unparseable Last-Event-ID {last_event_id!r} for session {presented_session_id}" ) owner = event_id_session_owner(last_event_id) if owner != presented_session_id: # Cursor belongs to a different session: tenant-isolation boundary. raise SessionCursorMismatch( f"Last-Event-ID {last_event_id!r} owned by {owner!r}, " f"presented under session {presented_session_id!r}" ) if seq < session.high_water - retained_window: # Cursor is outside the retained window; the client has an unrecoverable gap. return session, None return session, seq ```