# In what scenarios must an instance treat MCP streamable-session resumption as unsafe after SSE fallback negotiation **Date of Record:** 2026 November 12 **Scope:** Applies to MCP servers and clients using the HTTP transport with `Last-Event-ID`-based stream resumption when the client has negotiated down from a streamable HTTP POST response to a legacy SSE GET stream mid-session. Does not apply to pure SSE-only servers, pure streamable-HTTP servers that never fall back, or stdio transports. ## Core Distinction The MCP HTTP transport carries two things that look like the same session but are not: the *streamable session* (a server-side state machine keyed by `Mcp-Session-Id`, resumable via `Last-Event-ID` on a POST response stream) and the *SSE fallback stream* (a long-lived GET event channel that some deployments use when the streamable POST response cannot be held open — proxies that buffer responses, load balancers with short idle timeouts, or clients that cannot consume a streaming POST body). Resumption is only sound when the event ID space being resumed is the same event ID space the client last observed. When a session transitions from streamable POST to SSE GET fallback, the server typically maintains *two independent event ID sequences*: one for POST response streams, one for the SSE channel. A `Last-Event-ID` minted on one sequence and replayed against the other will either be silently accepted (delivering a wrong-but-monotonic event, causing duplicated or skipped tool calls) or rejected (forcing a full resync that the client may not implement). The non-obvious invariant: **`Mcp-Session-Id` identity is necessary but not sufficient for resumption.** The pair `(Mcp-Session-Id, event-id namespace)` must match. Fallback negotiation silently changes the namespace. ## What Was Observed In a deployment behind a buffering reverse proxy, a client established a session, issued `initialize`, then `tools/call` over a streamable POST. The proxy buffered the POST response, so the server's streamable response stream stalled past the client's read timeout. The client fell back to `GET /mcp` with `Accept: text/event-stream`, received a fresh SSE stream, and continued. Later, a transient network drop occurred. The client reconnected with `Last-Event-ID: 41` — the last ID it had seen on the SSE stream. The server, however, had been tracking the POST stream's event counter (which had reached 41 independently) and resumed the *POST* stream from event 41, replaying a `tools/call` result for a different invocation and skipping the SSE events 42–47 that contained a `notifications/progress` and a `tools/call` completion the client was awaiting. Observed failure signature: no protocol error, no 4xx, no `Mcp-Session-Id` mismatch. The client received a syntactically valid, monotonic event stream containing the wrong events. The only detectable symptom was a `tools/call` whose result arrived with a `requestId` the client had already resolved, followed by a hang on a request whose response was never delivered because it lived in the skipped range. ## Condition of Validity Resumption via `Last-Event-ID` is valid only when all of the following hold: 1. The event ID was minted by the same server-side event sequence that will service the resume request. 2. The transport mode (streamable POST response vs. SSE GET) is unchanged between the last observed event and the resume. 3. The server's event ID space is not reset by fallback negotiation — i.e., the server does not maintain per-transport counters. Condition 2 is the one that fails silently. Fallback negotiation is *mode-changing*, and mode changes are not represented in the MCP session state that the client can observe. The client cannot tell, from `Mcp-Session-Id` alone, which event namespace its `Last-Event-ID` belongs to. ## Decision Boundary An instance must treat resumption as **unsafe** — and must force a full session re-establishment rather than attempting `Last-Event-ID` replay — when any of these are true: - The client has performed SSE fallback negotiation at any point since the last full `initialize`, and the server does not advertise a transport-mode token in its event IDs or in a `Mcp-Session-Id`-adjacent header. - The last observed event ID is not strictly greater than the highest event ID the client observed before the fallback (a counter reset is implied). - The resume response's first event ID is not the immediate successor of the `Last-Event-ID` sent (a gap or a jump indicates a different namespace). - The server returns `200` with an event stream but no `Mcp-Session-Id` re-affirmation, or returns a `Mcp-Session-Id` that differs from the one held (treat as new session, discard pending request map). Conversely, resumption is safe when the server emits namespaced event IDs (e.g., `sse:42`, `post:41`) or a `Mcp-Transport-Mode` header that the client can echo, *and* the echoed mode matches the mode in which the last event was observed. ## Operational Effect When resumption is treated as unsafe, the client must: (a) abandon the `Last-Event-ID` replay, (b) re-issue `initialize` with the same `Mcp-Session-Id` if the server supports session re-attachment, or mint a new one, (c) re-send any in-flight `tools/call` whose result was not observed, and (d) reconcile locally by request ID rather than by event order. The cost is duplicate execution risk for non-idempotent tools — which is why the client must carry an idempotency key on `tools/call` and the server must deduplicate on it. The alternative — trusting a cross-namespace `Last-Event-ID` — produces silent divergence that is strictly worse than a loud resync. The operational rule: **fallback negotiation invalidates event-ID continuity unless the server explicitly namespaces event IDs by transport mode.** Absent that, the boundary between "resumable" and "must resync" is drawn at the first fallback, not at the first network error. ```python class UnsafeResumptionError(RuntimeError): """Raised when Last-Event-ID replay crosses a transport-mode boundary.""" class SessionState: def __init__(self, session_id: str, mode: str): self.session_id = session_id self.mode = mode # "streamable" or "sse" self.last_event_id: int | None = None self.mode_at_last_event: str | None = None self.namespaced_ids: bool = False def resume_stream(state: SessionState, last_event_id: int, server_mode: str, first_resumed_event_id: int, namespaced_ids: bool) -> None: if state.last_event_id is None: raise UnsafeResumptionError("no prior event observed; cannot resume") # Boundary 1: transport mode changed since last observed event. if state.mode_at_last_event is not None and state.mode_at_last_event != server_mode: if not namespaced_ids: raise UnsafeResumptionError( f"transport mode changed {state.mode_at_last_event!r} -> {server_mode!r} " f"without namespaced event IDs; Last-Event-ID={last_event_id} is in a " f"different namespace and must not be replayed" ) # Boundary 2: counter reset implied by non-successor first event. if first_resumed_event_id != last_event_id + 1: raise UnsafeResumptionError( f"resume stream began at {first_resumed_event_id}, expected " f"{last_event_id + 1}; event ID space reset implies unsafe resumption" ) # Boundary 3: server did not re-affirm session identity. state.last_event_id = first_resumed_event_id state.mode_at_last_event = server_mode ```