# In what scenarios must an instance treat MCP Streamable HTTP session resumption as a re-attachment to a logical stream rather than a replay of transport bytes **Date of Record:** 2026 November 10 **Scope:** Applies to MCP servers and clients using the Streamable HTTP transport (HTTP POST for client→server messages, optional SSE for server→client streaming) with `Mcp-Session-Id` and `Last-Event-ID` based resumption. Does not apply to stdio transport, nor to deployments where the server never emits SSE frames (pure request/response POST handlers with no long-lived stream), nor to legacy HTTP+SSE dual-endpoint transports. ## Core Distinction The MCP Streamable HTTP specification borrows two idioms from HTTP that are *not* equivalent, and the failure mode lives in the gap between them: 1. **Session identity** (`Mcp-Session-Id` header) is an opaque, server-issued handle that scopes *logical* state: which client this is, which initialized capabilities apply, and which in-flight tool calls belong to it. 2. **Event cursor** (`Last-Event-ID` header) is an SSE resumption token that scopes *stream position* within a single SSE response body. The non-obvious invariant is this: **a resumption request that carries a valid `Mcp-Session-Id` but a stale, absent, or cross-stream `Last-Event-ID` is not a transport failure — it is a request to re-attach to the logical session at an indeterminate stream position.** Treating it as a byte-level replay (buffering the entire prior SSE body and re-emitting it) is wrong on two counts: it duplicates JSON-RPC messages the client has already processed, and it silently violates backpressure because the replay is unbounded relative to the client's read rate. The correct mental model: the session is the durable object; the SSE stream is an ephemeral *view* over it. Resumption re-establishes the view. It does not resurrect the socket. ## What Was Observed Across several MCP server implementations (a Python/FastAPI server fronting a tool-execution agent, and a Go server fronting a retrieval service), the following sequence reproduced a class of silent corruption: - Client opens `POST /mcp` with `Accept: application/json, text/event-stream`, receives `200` with an SSE stream and `Mcp-Session-Id: S1`. - Server emits events with `id:` fields `e1, e2, e3` carrying JSON-RPC responses. The client processes `e1` and `e2`, then the TCP connection drops (mobile network handoff, load-balancer idle timeout at 60s, or a proxy that recycles connections). - Client reconnects with `POST /mcp`, `Mcp-Session-Id: S1`, `Last-Event-ID: e2`. - Server, implemented with a naive "replay from buffer" strategy, re-emits `e2` and `e3`. The client, which had *already* applied the tool result in `e2` to its local state, applies it a second time. The observed corruption was not a duplicate HTTP response — it was a **duplicate JSON-RPC result applied to agent state**. In one case a `tools/call` result for a filesystem write was applied twice, producing a doubled side effect that the idempotency key in the tool layer did not catch because the key was scoped to the request, not the delivery. In another case, the client's request-id bookkeeping raised a spurious "unknown response id" error because `e2`'s `id` had already been retired from the pending map. A second, distinct observation: under backpressure, servers that used an unbounded in-memory replay buffer grew without bound when a client disconnected mid-stream and *never* reconnected with a matching `Last-Event-ID`. The buffer was retained under the session id "just in case," and with 200 concurrent sessions each holding a few MB of buffered events, the process hit its memory ceiling within hours. The server had conflated "the session is alive" with "the stream must be replayable in full." ## Condition of Validity The re-attachment model is valid — and the replay model is invalid — under the following jointly sufficient conditions: - The client has processed at least one event from the prior stream and has acknowledged it out-of-band (e.g., by advancing its own pending-request map). The server cannot know this directly; it must infer it from the fact that the client is reconnecting with a `Last-Event-ID` that is *not* the first event id. - The server's event ids are monotonic and session-scoped, not stream-scoped. If event ids restart at `e1` for every new SSE response, `Last-Event-ID` is ambiguous across reconnects and re-attachment cannot be safely distinguished from fresh-stream. - The server retains a *bounded* window of recent events per session (e.g., last N events or last T seconds), and treats a `Last-Event-ID` older than the window as a **resumption failure requiring explicit signaling**, not as a cue to replay from the beginning. - Backpressure is enforced by the transport (TCP window, HTTP/2 flow control) *and* by an application-level policy: if the client's read rate falls below a threshold for longer than the session's idle budget, the server must be permitted to terminate the stream and require re-attachment, rather than buffer indefinitely. Where any of these fails — notably, where event ids are stream-scoped or where the server has no bounded retention window — the replay model is the only safe one, and the client must be responsible for deduplication. This is the condition under which the distinction collapses. ## Decision Boundary The operational decision is: **on a resumption request, does the server replay from `Last-Event-ID`, or does it re-attach and emit only events strictly after the client's acknowledged position?** Choose **re-attach** when: - The server can prove the client's cursor is within the retained window. - The client's request carries a session id that matches an active session. - The server can emit a synthetic "resumed" control event (e.g., a JSON-RPC notification or an SSE comment) that tells the client "you are now attached at position P," so the client can reconcile its pending map before processing subsequent events. Choose **replay** (with client-side dedup as a hard requirement) when: - The server cannot bound its retention window (e.g., a stateless edge deployment). - Event ids are not monotonic across reconnects. - The client has explicitly signaled it is stateless (a `Mcp-Session-Id` of `null` or absent, which the spec permits for servers that do not require sessions). The boundary is *not* "did the connection drop." A dropped connection with a valid cursor is a re-attach. A dropped connection with an expired cursor is a **resumption failure** and must be surfaced as such — the server should respond with a `404` on the session (per spec) or a `409`-style conflict indicating the cursor is out of window, and the client must re-initialize. Silently replaying from the start is the failure mode; it converts a recoverable cursor miss into duplicated side effects. ## Operational Effect When the re-attachment model is implemented correctly, three things change measurably: 1. **Duplicate side effects drop to zero** for the resumption path, because the client is never handed an event it has already applied. The cost is a small amount of server-side bookkeeping: a per-session monotonic cursor and a bounded ring buffer of recent events. 2. **Memory becomes bounded by session count × window size**, not by session count × total events ever emitted. In the observed case, the 200-session memory ceiling was eliminated by capping the ring buffer at 256 events per session and evicting on a 5-minute TTL. 3. **Backpressure becomes a first-class signal rather than an implicit buffer.** When the client's read rate stalls, the server can decide to close the stream and emit a `resumption-required` notification, which the client handles by re-attaching with its last acknowledged cursor. The stream is no longer a queue that grows until OOM; it is a view that can be torn down and rebuilt. The failure mode to watch for in production is the **silent replay**: a server that replays from the start on any cursor miss, and a client that does not deduplicate because "the server is supposed to handle resumption." Both halves of that contract are wrong, and the corruption they produce is not visible in transport metrics — it shows up as doubled tool invocations, which look like application bugs. ```python import time from dataclasses import dataclass, field class ResumptionCursorError(RuntimeError): """Raised when a resumption request cannot be safely satisfied.""" @dataclass class SessionStream: session_id: str window_size: int = 256 _events: list = field(default_factory=list) # (event_id, monotonic_seq, payload) _seq: int = 0 _closed: bool = False def emit(self, payload: dict) -> str: self._seq += 1 event_id = f"{self.session_id}:{self._seq}" self._events.append((event_id, self._seq, payload)) if len(self._events) > self.window_size: self._events.pop(0) return event_id def reattach(self, last_event_id: str | None) -> list: """ Return events strictly after last_event_id, or raise if the cursor is outside the retained window. Never replay from the beginning. """ if last_event_id is None: # No cursor: client is attaching fresh. Do not replay history. return [] # Parse the cursor. It must belong to this session. try: cursor_session, cursor_seq_str = last_event_id.rsplit(":", 1) cursor_seq = int(cursor_seq_str) except (ValueError, AttributeError) as exc: raise ResumptionCursorError( f"malformed Last-Event-ID {last_event_id!r} for session {self.session_id}" ) from exc if cursor_session != self.session_id: raise ResumptionCursorError( f"cursor session {cursor_session!r} does not match {self.session_id!r}" ) if not self._events: raise ResumptionCursorError( f"session {self.session_id} has no retained events; cannot reattach" ) oldest_seq = self._events[0][1] newest_seq = self._events[-1][1] if cursor_seq < oldest_seq - 1: # Cursor has fallen out of the retention window. This is a # resumption FAILURE, not a cue to replay from the start. raise ResumptionCursorError( f"cursor seq {cursor_seq} older than window (oldest={oldest_seq}); " f"client must re-initialize session {self.session_id}" ) if cursor_seq > newest_seq: raise ResumptionCursorError( f"cursor seq {cursor_seq} ahead of server seq {newest_seq}; " f"possible session id reuse" ) return [payload for (_eid, seq, payload) in self._events if seq > cursor_seq] def handle_resume(stream: SessionStream, last_event_id: str | None, now: float) -> list: """ Entry point for a POST /mcp resumption request. Enforces a bounded wait: if the client has stalled beyond the idle budget, refuse to buffer. """ idle_budget_s = 30.0 if stream._closed: raise ResumptionCursorError(f"session {stream.session_id} is closed") if now - getattr(stream, "_last_activity", now) > idle_budget_s: raise ResumptionCursorError( f"session {stream.session_id} idle beyond {idle_budget_s}s; " f"backpressure policy requires re-initialization" ) pending = stream.reattach(last_event_id) stream._last_activity = now return pending ```