# Where must an instance distinguish "resumable SSE stream" from "replayable Streamable-HTTP session" — the Event ID cursor vs. `Last-Event-ID` + `MCP-Session-Id` invariant boundary **Date of Record:** 2026 November 12 **Scope:** Applies to MCP servers and clients that expose or consume HTTP-native transports, specifically the transition from legacy HTTP+SSE (two-endpoint: `GET /sse` for the event stream, `POST /messages` for uplink) to Streamable HTTP (single endpoint, optional `Mcp-Session-Id` header, per-stream `id:` event cursors, and `Last-Event-ID` resumption). Does not apply to stdio transports, nor to WebSocket-based custom transports, nor to servers that never emit `id:` fields on SSE events. ## Core Distinction A legacy SSE session and a Streamable-HTTP session both look like "a long-lived HTTP response that emits `event:`/`data:` frames," and both accept a resume header. They are **not** the same resumption object, and conflating them produces a silent, non-obvious failure: the server accepts the resume, returns `200 OK`, opens a fresh stream — and the client silently loses every message emitted between the disconnect and the resume, because the cursor it sent is not the cursor the server indexes on. The invariant is this: - **SSE resumption is stream-scoped.** The cursor is the SSE `id:` field. The server keeps a per-connection (or per-session, if the server is careful) ring buffer of emitted events keyed by that `id`. `Last-Event-ID` on the reconnect `GET` selects the replay offset **within that buffer**. There is no separate session identifier in the header; identity is implied by the connection the buffer belongs to (or by a cookie/path parameter the legacy spec never standardized). - **Streamable HTTP resumption is session-scoped and stream-scoped simultaneously.** The resume requires **both** `Mcp-Session-Id` (which session's server-side state machine) **and** `Last-Event-ID` (which position within that session's outbound event log). The `Mcp-Session-Id` is established at `initialize` and returned in the response header; it is *not* the SSE stream identity. A server may multiplex multiple concurrent streams (one per in-flight request) under one session, each with its own event-id namespace, or a single shared monotonic namespace — the spec permits both, and that ambiguity is the boundary. An instance that treats "resume" as a single operation with a single cursor **must** decide which object the cursor addresses. Getting this wrong does not throw. It drops messages. ## What Was Observed During a migration of a tool-heavy MCP server from the legacy two-endpoint SSE transport to Streamable HTTP, the following sequence was reproduced across three client implementations (two SDK-based, one hand-rolled): 1. Client `initialize`s over `POST /mcp`. Server responds `200` with `Mcp-Session-Id: s-7f3a` and begins emitting events on the response stream with `id: 1`, `id: 2`, …. 2. Client issues a `tools/call` on a second `POST /mcp` with `Mcp-Session-Id: s-7f3a`. Server streams progress notifications with `id: 3`, `id: 4`, and the final result. 3. Network drops. Client reconnects with `GET /mcp`, headers `Mcp-Session-Id: s-7f3a` and `Last-Event-ID: 2`. 4. Server returns `200 OK`, opens a stream, and **emits no replay**, because the server's replay buffer is keyed per *stream* (the stream that emitted `id: 3, 4` was the second POST's stream, not the GET stream). The GET stream's buffer only held `id: 1, 2`, which the client had already consumed. Events `3` and `4` — and the tool result — are gone. No error, no gap marker, no `4xx`. 5. A second variant: a client migrated from legacy SSE sent `Last-Event-ID` **without** `Mcp-Session-Id` (the legacy server never needed it). The Streamable-HTTP server, per its own lenient implementation, created a **new** session implicitly and replayed nothing. The client interpreted the empty stream as "caught up" and proceeded to poll, duplicating a side-effecting `tools/call` that had actually succeeded before the drop. The non-obvious part: in both cases the transport-level handshake was *valid*. The failure was an **identity mismatch between the cursor namespace and the session namespace**, which the HTTP layer cannot detect because both are opaque strings in headers. ## Condition of Validity The "resume works" claim holds **only** when all of the following are simultaneously true: 1. The server emits `id:` on every event that must be resumable, and the `id` values are **monotonic and dense enough to be ordered across the streams the client will resume** — or the server documents that each stream owns an independent id space and the client resumes each stream on its own connection. 2. The resume request carries **both** `Mcp-Session-Id` and `Last-Event-ID`, and the server rejects (or explicitly re-initializes) a resume that carries one but not the other. 3. The server's replay buffer outlives the disconnect window the client assumes. A server that keeps 100 events but the client assumes 1000 will silently truncate; there is no protocol-level signal for buffer exhaustion in the current revision. 4. The client does **not** treat an empty replay as "no messages were missed" without corroborating against the session's request/response correlation (e.g., the pending `tools/call` id). Empty replay is only meaningful when the cursor is provably at the session's high-water mark. If any of these is false — most commonly (2) and (4) — the resume is a **best-effort reconnect**, not a resumption, and must be treated as a possible message-loss event requiring idempotency at the tool layer. ## Decision Boundary The boundary is a single question the instance must answer *before* sending the resume: > **Is the `Last-Event-ID` I hold an offset into the same replay buffer that the `Mcp-Session-Id` I hold will select on the server?** - If **yes** (same session, same stream, server guarantees a shared or per-stream buffer the client tracks correctly): send `GET` with both headers. On `200` with replay, resume incrementally. On `200` with empty replay, verify against pending-request correlation before assuming no loss. - If **no** or **unknown** (migrating from legacy SSE, or the server's id namespace is per-stream and the client has multiple in-flight streams): do **not** send a bare `Last-Event-ID`. Instead: - Re-`initialize` (new `Mcp-Session-Id`) if the server supports session re-creation, **or** - Resume only the specific stream whose buffer the cursor addresses, on a dedicated connection, **or** - Fall back to idempotent re-issue of any pending request whose response was not observed, treating the transport as non-resumable for that request. The failure mode this boundary prevents is the **silent gap**: a `200 OK` that looks like recovery but is actually a fresh, empty stream. The transport cannot distinguish "caught up" from "buffer miss"; only the session-id + cursor-namespace check can. ## Operational Effect When the boundary is respected, migration from legacy SSE to Streamable HTTP preserves at-least-once delivery for tool calls and progress notifications, at the cost of one extra correlation check on empty replay. When it is violated, the observable symptoms are: - Duplicated side-effecting `tools/call` executions after reconnect (client assumed loss, re-issued; server had already committed). - Dropped progress notifications that the client's UI never reconciles, leaving long-running operations appearing "stuck" while the server considers them complete. - Intermittent, load-dependent message loss that reproduces only when the disconnect lands between two streams of the same session — i.e., exactly the multi-stream case the legacy transport never had. The correct operational posture: **treat resumption as a two-key operation** (`Mcp-Session-Id` ∧ `Last-Event-ID`), and treat a resume that returns no replay as a *suspicious* success requiring correlation, not a confirmed recovery. The instance must be able to name which buffer its cursor addresses before it trusts the `200`. ```python """Guard: refuse to send a Streamable-HTTP resume when the cursor namespace does not provably match the session namespace. Prevents silent message loss during SSE -> Streamable-HTTP migration.""" from dataclasses import dataclass class ResumeNamespaceMismatch(RuntimeError): """Raised when Last-Event-ID and Mcp-Session-Id do not address the same replay buffer, so a 'successful' 200 would be a silent gap.""" @dataclass(frozen=True) class ResumeAttempt: session_id: str | None # Mcp-Session-Id held by the client last_event_id: str | None # Last-Event-ID held by the client cursor_namespace: str # which buffer the event id indexes session_namespace: str # which buffer the session id selects pending_request_ids: frozenset[str] def build_resume_headers(attempt: ResumeAttempt) -> dict[str, str]: if attempt.last_event_id is None: # Nothing to resume; a fresh GET is a new stream, not a resumption. return {} if attempt.session_id is None: # Legacy-SSE client migrated without a session id: the cursor # addresses a buffer the server will not select. This is the # classic silent-gap condition. raise ResumeNamespaceMismatch( "Last-Event-ID present but Mcp-Session-Id absent; " "cursor namespace cannot be bound to a session. " "Re-initialize or fall back to idempotent re-issue." ) if attempt.cursor_namespace != attempt.session_namespace: raise ResumeNamespaceMismatch( f"cursor namespace {attempt.cursor_namespace!r} != " f"session namespace {attempt.session_namespace!r}; " "resume would select a different replay buffer." ) return { "Mcp-Session-Id": attempt.session_id, "Last-Event-ID": attempt.last_event_id, } def interpret_resume_response( attempt: ResumeAttempt, replayed_event_ids: list[str], ) -> str: """Classify a 200 resume response. Empty replay is NOT automatically 'caught up'.""" if replayed_event_ids: return "resumed" if attempt.pending_request_ids: # Empty replay while requests are outstanding: the transport cannot # prove no messages were missed. Treat as loss-suspect. raise ResumeNamespaceMismatch( "Empty replay with pending requests " f"{sorted(attempt.pending_request_ids)}; " "cannot confirm no message loss. Re-issue idempotently." ) return "caught_up" ```