# How should an instance distinguish a resumable MCP SSE session from a silently re-established streamable-HTTP session after transport migration **Date of Record:** 2026 November 7 **Scope:** Applies to MCP servers and clients that expose or consume the HTTP-native transport, specifically when a deployment migrates from the legacy HTTP+SSE transport (two-endpoint: `GET /sse` event stream plus `POST /message`) to the streamable-HTTP transport (single endpoint, `POST` with optional `GET` for server-initiated messages), and when `Last-Event-ID`-based resumption is expected to survive that migration. Does not apply to stdio transport, nor to pure streamable-HTTP deployments that never offered SSE, nor to resumption within a single transport generation. ## Core Distinction The boundary that matters is not "is the session resumable" but "did the session identity survive the transport change, and does the resumption token mean the same thing on both sides of it." In the legacy SSE transport, resumption semantics are carried by two loosely coupled identifiers: the session identifier (returned in the `endpoint` event, typically embedded in the `POST /message?sessionId=...` URL) and the per-event `id:` field used by the client's `Last-Event-ID` replay request. The server is expected to buffer events for a disconnected stream and replay from the last acknowledged ID. In the streamable-HTTP transport, the session identifier moves into the `Mcp-Session-Id` header, and stream resumption is expressed via `Last-Event-ID` on a re-issued `GET`. Critically, the streamable-HTTP spec makes event IDs and replay *optional*: a server may legitimately not support resumption at all. So a client that migrates from SSE — where it learned to trust `Last-Event-ID` — can present a token to a streamable-HTTP server that accepts the request, opens a fresh stream, and silently starts from "now," having discarded the events the client believed were pending. The non-obvious invariant: **a `200 OK` on a resumed stream is not evidence that resumption occurred.** The only positive evidence is either (a) the first replayed event carries an ID at or after the requested `Last-Event-ID`, or (b) the server explicitly signals that replay is unsupported/complete via a protocol-level marker rather than by simply not sending anything. Absence of replay and successful replay are indistinguishable from the wire unless the client tracks event-ID continuity. ## What Was Observed During a staged migration of an MCP server fleet, a subset of clients held open SSE sessions against legacy instances while the load balancer began routing reconnects to streamable-HTTP instances. On network blip, the client reconnected with `Last-Event-ID: 4821` to the new endpoint. The new instance: 1. Accepted the `GET` with the stale `Last-Event-ID` header (it was not part of the new session's tracking state). 2. Issued `200 OK` and a fresh `Mcp-Session-Id`, because the old session ID was unknown to it and the server treated the reconnect as a new session. 3. Began streaming only newly generated events, starting at a local counter near `1`. The client observed a live stream, assumed continuity, and applied tool-call results out of order relative to its last-known state. The failure was silent: no error status, no gap marker. Two secondary observations: - Servers that *did* honor `Last-Event-ID` across the migration produced duplicate event IDs (old global counter vs. new per-session counter), so clients deduplicating by ID dropped genuinely new events. - The `Mcp-Session-Id` header on the response was the only reliable discriminator that the session had been re-established rather than resumed — but only if the client had recorded the prior session ID and compared. ## Condition of Validity This failure mode is valid (i.e., the hazard exists) precisely when all of the following hold: - The client has a non-empty `Last-Event-ID` from a prior transport generation. - The receiving server does not share session/event-ID state with the instance that issued that token (different process, different store, or different transport implementation). - The server's streamable-HTTP implementation treats an unknown `Last-Event-ID` as "start fresh" rather than rejecting it. - The client does not independently verify event-ID monotonicity or session-ID continuity. It is *not* valid when the server rejects unknown `Last-Event-ID` with a 4xx, when session state is shared across the fleet, or when the client treats any session-ID change as a hard reset and re-initializes rather than resuming. ## Decision Boundary The operational rule: **on any transport-generation change, treat resumption as unproven until event-ID continuity is confirmed; if the session ID differs from the one associated with the presented `Last-Event-ID`, do not resume — re-initialize.** Concretely, a client (or a proxy mediating the transport) should: - Record the `Mcp-Session-Id` alongside every persisted `Last-Event-ID`. - On reconnect, if the response `Mcp-Session-Id` differs from the recorded one, discard the resumption attempt and start a clean session, replaying any idempotent pending work explicitly. - If IDs match, still require the first replayed event ID to be `>=` the requested `Last-Event-ID`; a first event with a lower ID indicates a counter reset and must be treated as a gap, not a replay. - Never infer "no events pending" from an empty initial replay window; require an explicit end-of-replay or a heartbeat before declaring the stream caught up. Servers should reject (not silently accept) a `Last-Event-ID` that predates the current session, returning a distinguishable status so the client can choose re-initialization. ## Operational Effect The practical consequence is that SSE-to-streamable-HTTP migration is not a transport swap but a session-identity migration. Clients that carry `Last-Event-ID` across it without binding it to a session ID will experience silent, ordering-corrupting event loss under exactly the conditions (network blips, rolling deploys, autoscaling) where resumption is supposed to help. The mitigation is cheap: bind the resumption token to the session identity and fail closed on mismatch. The cost of not doing so is a class of bug that produces no error, no log line, and no stack trace — only divergent state discovered much later. ```python from __future__ import annotations from dataclasses import dataclass class ResumptionGapError(RuntimeError): """Raised when a resumed MCP stream cannot be proven continuous.""" @dataclass(frozen=True) class StreamCursor: session_id: str last_event_id: int def verify_resumption( cursor: StreamCursor, response_session_id: str, first_replayed_event_id: int | None, ) -> None: # Session identity must survive the transport generation change. if response_session_id != cursor.session_id: raise ResumptionGapError( f"session id changed on reconnect: had {cursor.session_id!r}, " f"got {response_session_id!r}; resumption is unproven, re-initialize" ) # A stream that opens with no replay is ambiguous: it may mean # "nothing pending" or "replay unsupported". Refuse to guess. if first_replayed_event_id is None: raise ResumptionGapError( "no replayed event on resume; cannot distinguish caught-up " "from replay-unsupported, refuse to treat as continuous" ) # Event IDs must be monotonic across the resume point. A lower ID # means the server's counter reset, i.e. a silent gap. if first_replayed_event_id < cursor.last_event_id: raise ResumptionGapError( f"event id regression: requested >= {cursor.last_event_id}, " f"got {first_replayed_event_id}; counter reset implies gap" ) def resume_stream(cursor: StreamCursor, response_session_id: str, first_event_id: int | None) -> None: verify_resumption(cursor, response_session_id, first_event_id) ```