# How should an instance distinguish SSE stream resumption from session resumption in MCP HTTP-native transport **Date of Record:** 2026 November 9 **Scope:** Applies to MCP servers and clients using the HTTP-native transport with `text/event-stream` responses, `Last-Event-ID` resumption, and the `Mcp-Session-Id` header. Does not apply to stdio transport, nor to plain JSON request/response MCP endpoints that never emit SSE. ## Core Distinction In MCP's HTTP-native transport, two independent resumability mechanisms coexist and are routinely conflated: 1. **Stream resumption** — a broken SSE connection is re-established by the client sending `Last-Event-ID`, asking the server to replay events after that ID on the *same logical stream*. This is governed by SSE semantics and is scoped to a single HTTP response body. 2. **Session resumption** — a client that lost all connection state re-attaches to a *server-side session* identified by `Mcp-Session-Id`, and the server must decide what to do with any in-flight streams and pending server-to-client requests associated with that session. The non-obvious invariant: `Last-Event-ID` is meaningful only within the lifetime of the stream that produced it. A session resumption (new connection, same `Mcp-Session-Id`, no `Last-Event-ID`) does **not** entitle the client to replay, and a stream resumption does **not** create a new session. Servers that treat `Mcp-Session-Id` as a replay cursor, or that treat `Last-Event-ID` as a session identifier, will either duplicate tool-call side effects or silently drop server-initiated requests (sampling, elicitation, roots list-changed). ## What Was Observed A server implemented event replay keyed on a monotonic event counter stored per session, not per stream. Client A opened a stream, received events 1–5, and the connection dropped. Client A reconnected with `Last-Event-ID: 5` and `Mcp-Session-Id: S`. The server replayed events 6–9 — correct. Later, Client A's process restarted, discarded its event ID, and reconnected with only `Mcp-Session-Id: S`. The server, seeing a session with a cursor at 9, replayed nothing and considered the session "caught up." But the server had also queued two `sampling/createMessage` requests into the session's pending-request table that were only ever delivered on the *old* stream. Those requests were never re-emitted on the new stream; they remained pending until timeout, and the client never knew they existed. The inverse failure was also observed: a client reconnected with `Last-Event-ID: 5` but a *fresh* `Mcp-Session-Id` (its session had been garbage-collected server-side). The server accepted the event ID, replayed events 6–9 from a global log, but the events referenced request IDs from the dead session. The client issued responses against request IDs the server no longer recognized, and the server returned 404 on the response POST, which the client interpreted as a transport failure and retried — duplicating a `tools/call`. ## Condition of Validity The separation holds only when all of the following are true: - The server maintains a **per-stream** event buffer distinct from the **per-session** state, and `Last-Event-ID` lookups are scoped to the stream that emitted that ID (or to a session-scoped log whose entries are tagged with the stream they belong to). - Pending server-to-client requests are stored on the **session**, and on any new stream attached to that session the server **re-emits** all still-pending requests (or explicitly cancels them with a JSON-RPC error) rather than assuming the old stream delivered them. - The server rejects a `Last-Event-ID` that does not belong to the supplied `Mcp-Session-Id` (or to a stream within it) with a distinct error, rather than replaying from a global log. - Session expiry is independent of stream expiry: a session may outlive many streams, and a stream may die without the session dying. If any of these is violated, the two mechanisms collapse into one and the failure modes above become reachable. ## Decision Boundary On reconnect, the server must branch on the *combination* of headers, not either alone: - `Mcp-Session-Id` present, `Last-Event-ID` present, ID valid for that session → **stream resumption**: replay events after the ID on the new stream, do not re-emit already-delivered pending requests, do not reset session state. - `Mcp-Session-Id` present, `Last-Event-ID` absent → **session resumption on a new stream**: attach a fresh stream to the session, re-emit all pending server-to-client requests (or cancel them), do not replay past events. - `Mcp-Session-Id` present, `Last-Event-ID` present but not valid for that session → **reject** with a resumption error; do not fall back to global replay. - `Mcp-Session-Id` absent, `Last-Event-ID` present → **reject**; an event ID without a session is meaningless. - `Mcp-Session-Id` absent, `Last-Event-ID` absent → new session (or 400 if the server requires session establishment). The boundary is exactly: *an event ID is a position within a stream; a session ID is an identity across streams.* Neither substitutes for the other. ## Operational Effect When the boundary is respected, client restarts and flaky connections are recoverable without duplicate side effects, and server-initiated requests survive stream churn. When it is violated, the observable symptoms are (a) `tools/call` duplication after client restart with a stale event ID, (b) permanently pending `sampling`/`elicitation` requests that time out with no client-visible cause, and (c) 404 storms on JSON-RPC response POSTs when event IDs outlive their sessions. Instrumentation should log both headers on every reconnect and emit a counter for "replayed events" vs. "re-emitted pending requests," since these should move independently. ```python class ResumptionBoundaryError(RuntimeError): """Raised when Last-Event-ID and Mcp-Session-Id are used inconsistently.""" pass class StreamEventLog: def __init__(self): # session_id -> stream_id -> list of (event_id, payload) self._events = {} # session_id -> list of pending server-to-client requests self._pending = {} def register_session(self, session_id): self._events.setdefault(session_id, {}) self._pending.setdefault(session_id, []) def append(self, session_id, stream_id, event_id, payload): self._events[session_id].setdefault(stream_id, []).append((event_id, payload)) def queue_server_request(self, session_id, request): self._pending[session_id].append(request) def resume(self, session_id, stream_id, last_event_id): if session_id not in self._events: raise ResumptionBoundaryError( f"unknown session {session_id!r}; cannot resume stream" ) if last_event_id is not None: # Stream resumption: replay only, do not touch pending requests. streams = self._events[session_id] if stream_id not in streams: raise ResumptionBoundaryError( f"Last-Event-ID {last_event_id!r} presented for unknown stream " f"{stream_id!r} in session {session_id!r}; refusing global replay" ) log = streams[stream_id] ids = [eid for eid, _ in log] if last_event_id not in ids: raise ResumptionBoundaryError( f"Last-Event-ID {last_event_id!r} not valid for stream " f"{stream_id!r} in session {session_id!r}" ) idx = ids.index(last_event_id) return {"replay": [p for _, p in log[idx + 1:]], "reemit": []} # Session resumption on a new stream: re-emit pending requests, no replay. reemit = list(self._pending[session_id]) self._pending[session_id] = [] return {"replay": [], "reemit": reemit} def handle_reconnect(log, headers): session_id = headers.get("Mcp-Session-Id") last_event_id = headers.get("Last-Event-ID") stream_id = headers.get("X-Stream-Id") if last_event_id is not None and session_id is None: raise ResumptionBoundaryError( "Last-Event-ID supplied without Mcp-Session-Id; event IDs are " "stream-scoped and cannot be interpreted without a session" ) if session_id is None: raise ResumptionBoundaryError( "no Mcp-Session-Id; client must establish a session before resuming" ) return log.resume(session_id, stream_id, last_event_id) ```