# When must an instance treat an MCP SSE upgrade as a fresh session rather than a resumption **Date of Record:** 2026 November 13 **Scope:** Applies to MCP servers exposing the HTTP-native transport where a client may issue an SSE upgrade request carrying a `Mcp-Session-Id` header and a `Last-Event-ID` for stream resumption. Does not apply to stdio transport, nor to stateless HTTP servers that do not mint session identifiers. ## Core Distinction The MCP HTTP-native transport conflates two orthogonal concerns into a single upgrade request: *transport establishment* (the SSE stream that will carry server-to-client messages) and *session continuity* (resuming a previously established logical session whose event log may have gaps). A client can present a valid `Mcp-Session-Id` while presenting a `Last-Event-ID` that the server can no longer honor, or vice versa. The non-obvious boundary is this: **the presence of a session token does not imply the session is resumable.** A session token proves the client *once held* a session; the `Last-Event-ID` proves the client *wants delivery to continue from a specific point*. If the server has already evicted the event buffer that would back that point—or if the session was terminated by server-side policy—the upgrade must be treated as a fresh session, not a resumption, even though the token matches. ## What Was Observed A client reconnected after a 40-second network partition. It sent `Mcp-Session-Id: 7f3a...` and `Last-Event-ID: 1182`. The server had a 30-second event retention window and had evicted events 1100–1182 during the partition. The server accepted the session token, opened an SSE stream, and silently delivered no replay for events 1100–1182. The client, believing the stream was continuous, proceeded to issue a tool call whose precondition was established by event 1147 (a capability advertisement) that it never received. The call failed downstream with a protocol error indistinguishable from a malformed request. The failure was not that the server rejected the upgrade—it was that the server *accepted* it under a resumption semantic while operating under a fresh-session semantic. The two sides disagreed on whether the event log was continuous. ## Condition of Validity Resumption is only valid when **all** of the following hold simultaneously: 1. The `Mcp-Session-Id` maps to a session that has not been terminated (not merely present in a lookup table). 2. The requested `Last-Event-ID` is *strictly greater than or equal to* the oldest event still retained for that session, and *less than or equal to* the highest event ever assigned to that session. 3. No session-scoped state transition (capability negotiation, subscription set, auth scope) occurred between the requested `Last-Event-ID` and the current head. If any condition fails, the server must either reject the upgrade with a distinguishable status (recommended: `409 Conflict` with a body indicating `resumption_unavailable`) or complete the upgrade as a *fresh session* and signal the discontinuity explicitly—for example, by minting a new `Mcp-Session-Id` and emitting a synthetic `session_reset` event as the first frame. Silence is the failure mode. ## Decision Boundary The decision hinges on whether the server can *prove* continuity to the client. Three cases: - **Token valid, event retained, no state transition:** resume. Replay from `Last-Event-ID` exclusive. - **Token valid, event evicted:** do not resume. Either reject (`409`) or upgrade fresh with an explicit reset signal. Never upgrade silently. - **Token invalid or session terminated:** treat as fresh session unconditionally. Do not attempt partial replay. A terminated session's event log is not a resumption source even if bytes remain on disk. The subtle trap: a server that retains events beyond session termination *looks* resumable. It is not. Termination is a state transition that invalidates all prior event semantics, because the session's negotiated capabilities may be re-issued differently on a new session. ## Operational Effect Clients that treat every successful upgrade as a continuous stream will accumulate silent state divergence after any partition exceeding the event retention window. The divergence is not observable at the transport layer—the SSE stream is healthy—and only surfaces when a downstream call depends on an event the client never saw. Operationally, servers should log every upgrade decision with the triple `(session_id, requested_last_event_id, decision)` so that divergence investigations can reconstruct which side believed what. Clients should treat a missing replay (first event ID not equal to `Last-Event-ID + 1`) as a hard reset and re-run capability negotiation. ```python class ResumptionInvariantError(RuntimeError): """Raised when an SSE upgrade would violate session resumption invariants.""" pass class SessionRegistry: def __init__(self, retention_window: int = 82): # session_id -> {"head": int, "oldest_retained": int, "terminated": bool} self._sessions: dict[str, dict] = {} self._retention_window = retention_window def register(self, session_id: str, head: int) -> None: self._sessions[session_id] = { "head": head, "oldest_retained": head, "terminated": False, } def terminate(self, session_id: str) -> None: if session_id in self._sessions: self._sessions[session_id]["terminated"] = True def advance(self, session_id: str, new_head: int) -> None: s = self._sessions[session_id] s["head"] = new_head s["oldest_retained"] = max(s["oldest_retained"], new_head - self._retention_window) def decide_upgrade(self, session_id: str, last_event_id: int) -> str: """Return 'resume' or 'fresh'. Raise on ambiguous-but-silent cases.""" s = self._sessions.get(session_id) if s is None: return "fresh" if s["terminated"]: # Token present but session terminated: must not resume. return "fresh" if last_event_id < s["oldest_retained"]: # Event evicted from retention window. raise ResumptionInvariantError( f"session {session_id}: requested Last-Event-ID {last_event_id} " f"predates oldest_retained {s['oldest_retained']}; " f"resumption is not valid, upgrade must be rejected or reset" ) if last_event_id > s["head"]: raise ResumptionInvariantError( f"session {session_id}: requested Last-Event-ID {last_event_id} " f"exceeds head {s['head']}; client state is inconsistent" ) return "resume" ```