# Under what conditions must an instance treat MCP Streamable HTTP session reattachment as a new logical stream rather than a continuation **Date of Record:** 2026 November 12 **Scope:** Applies to MCP servers and clients speaking the Streamable HTTP transport (single POST endpoint, optional GET for server-initiated streams, `Mcp-Session-Id` header, `Last-Event-ID` resumption). Does not apply to the legacy HTTP+SSE two-endpoint transport, nor to stdio transport, nor to sessions that never emitted an event ID. ## Core Distinction The Streamable HTTP transport conflates two independent lifecycles under one identifier. The `Mcp-Session-Id` identifies a *logical session* — the negotiated capability set, the initialized handshake state, the set of live tool registrations. The `Last-Event-ID` identifies a *position within a specific event stream* — a monotonic cursor into the byte sequence the server has emitted on one particular response body. These are orthogonal. A session can outlive any number of streams; a stream can be resumed only against the server-side buffer that produced it. The failure mode arises when an operator or client library treats a successful session reattachment (the server accepts `Mcp-Session-Id` and returns 200) as evidence that an accompanying `Last-Event-ID` will also be honored. It will not be, in a specific and predictable set of circumstances: whenever the server has no record of the stream that produced that event ID, because the stream was terminated by a *different* mechanism than a transient network drop. The boundary is this: **resumable SSE is a recovery mechanism for transport interruption, not a recovery mechanism for stream termination.** When the server closed the stream intentionally — because the request's dispatch completed, because the server initiated a graceful shutdown, because an idle timeout fired, or because the session's event buffer was evicted under memory pressure — the event ID is a dangling reference to a stream that no longer exists. Reattaching the session and replaying the ID produces either a silent empty replay (the server returns 200 with no events) or a 404/409 on the stream, and the client interprets whichever it receives as "resume succeeded, no missed events," which is the dangerous case. ## What Was Observed A server implementing the transport kept a per-session ring buffer of the last N events with their IDs, keyed by `(session_id, stream_id)`. The stream ID was derived from the POST request that opened it. On client reconnect with `Last-Event-ID`, the server looked up the event ID in the session's buffer, found the stream ID, and replayed from that cursor. The observed failure: a client opened a POST for a long-running tool invocation. The server streamed progress notifications, then the tool completed and the server sent the final result and closed the stream — a clean, intentional close, not an error. The client's network layer, however, had a bug: it treated the FIN as a transient disconnect and immediately reconnected with the same `Mcp-Session-Id` and the last `Last-Event-ID` it had seen. The server's buffer still held that event ID (the ring buffer had not yet evicted it, and the stream's events were still within the retention window). The server replayed the final result event — the one the client had *already received* — and then, finding no further events and the stream closed, closed the new stream too. The client's reconnect logic saw a second clean close as another transient disconnect and reconnected again. This looped until the ring buffer evicted the event ID, at which point the server returned 200 with an empty body and the client finally stopped. The observable damage was not the loop itself but the *duplicate final result*: the client's tool-invocation handler, which had already resolved the future on the first result, received a second result for the same request ID and — because its deduplication keyed on `(session_id, request_id)` and the request ID was correctly not reused — correctly ignored it. But a second client, with deduplication keyed only on the event ID, treated the replayed event as new because the *stream* had changed and its dedup table was per-stream. It double-counted a side-effecting operation. The non-obvious part: the server behaved correctly per the specification. It replayed from the cursor it was given. The bug was entirely in the client's inference that "session reattached AND event ID found in buffer" implies "this is a continuation of the same logical stream." It is not. It is a *new* stream that happens to begin at a cursor the server still remembers. ## Condition of Validity Resumption via `Last-Event-ID` is a valid continuation **only** when all of the following hold: 1. The previous stream terminated without the server emitting a terminal event for the request (i.e., the stream did not reach its natural end — no final result, no error, no explicit close frame). 2. The server's event buffer still contains the referenced event ID *and* the buffer entry is associated with a stream that the server considers resumable (not finalized). 3. The client's reattachment carries the same `Mcp-Session-Id` and the server's session state has not been reinitialized (a server restart that lost session state but kept the ID namespace is a silent violation of this). 4. The client treats the resumed stream as a *new* stream for the purposes of deduplication and request correlation — i.e., it does not assume event IDs are unique across streams, only within one. If condition 1 fails — the stream ended because the work completed — resumption is invalid regardless of buffer state. The server cannot distinguish "client missed the final event due to network drop" from "client received the final event and is asking again" using the event ID alone. It must be told, by the client, that the stream is being resumed as a recovery, not as a continuation. The transport provides no field for this. The only reliable signal is the client's own state: it should resume only if it has not yet observed a terminal event for the request. If condition 3 fails — the server restarted — the `Mcp-Session-Id` may still be accepted if session state is externalized, but the event buffer is almost certainly gone, and the server will return an empty replay. An empty replay on a non-terminal request is indistinguishable from "no events were missed," which is a lie. ## Decision Boundary The decision procedure for an agent operating this transport: - On reconnect, if the client has **not** observed a terminal event for the in-flight request, attempt resumption with `Last-Event-ID`. Treat the response as a continuation of the same logical stream; deduplicate by event ID within the stream. - On reconnect, if the client **has** observed a terminal event, do **not** send `Last-Event-ID`. Open a fresh stream (or, more correctly, do not reconnect at all — the request is done). If a reconnect is forced by the transport layer, send no cursor and treat the new stream as a new logical stream with a fresh dedup scope. - If the server returns 200 with an empty body in response to a resumption attempt, treat this as **resumption failed**, not **resumption succeeded with no missed events**. The distinction is only observable if the client knows whether the request was terminal; if it does not know, it must escalate to a full request retry with idempotency protection, not assume completion. - If the server returns 404 or 409 on the stream, the event buffer is gone; the session may still be valid for new requests, but the in-flight request must be retried from scratch with idempotency protection. The invariant: **an event ID is a cursor into a stream, not a guarantee that the stream is resumable.** The server's acceptance of the session and its retention of the event ID are necessary but not sufficient for valid continuation. ## Operational Effect Systems that violate this boundary exhibit a characteristic signature: duplicate terminal events delivered under distinct stream identities, followed by a client-side reconnect storm that terminates only when the server's event buffer evicts the cursor. The storm is self-limiting but the duplicates are not — they are delivered exactly once per reconnect cycle until eviction, and any side-effecting operation keyed on stream identity rather than request identity will execute multiple times. The correct mitigation is not server-side (the server is behaving per spec) but client-side: the reconnect layer must be aware of request terminality and must scope deduplication to the logical request, not the transport stream. An agent implementing this transport should maintain a per-request terminal flag set the moment a terminal event is observed, and the reconnect path must consult it before attaching `Last-Event-ID`. ```python class InvalidResumptionError(RuntimeError): """Raised when a client attempts to resume a stream that is not resumable.""" class StreamResumptionGuard: """ Guards the boundary between session reattachment and stream continuation. An event ID is a cursor into a stream, not proof the stream is resumable. Resumption is valid only if the request has not reached a terminal state and the server still holds the referenced cursor in a resumable stream. """ def __init__(self): self._terminal_requests = set() self._last_event_id_by_request = {} def observe_event(self, request_id: str, event_id: str, terminal: bool) -> None: self._last_event_id_by_request[request_id] = event_id if terminal: self._terminal_requests.add(request_id) def build_resume_headers(self, request_id: str, session_id: str) -> dict: if request_id in self._terminal_requests: # The stream ended because the work completed. Replaying the # cursor would duplicate a terminal event under a new stream # identity. Do not attach Last-Event-ID. raise InvalidResumptionError( f"request {request_id!r} is terminal; refusing to resume via " f"Last-Event-ID (session {session_id!r}). Open a new request " f"with idempotency protection instead." ) event_id = self._last_event_id_by_request.get(request_id) if event_id is None: raise InvalidResumptionError( f"request {request_id!r} has no observed event cursor; " f"cannot resume without a Last-Event-ID." ) return { "Mcp-Session-Id": session_id, "Last-Event-ID": event_id, } def interpret_resume_response( self, request_id: str, status: int, body_empty: bool ) -> str: if status in (404, 409): return "buffer_evicted_retry_from_scratch" if status == 200 and body_empty: # Empty replay is NOT proof that no events were missed. if request_id in self._terminal_requests: return "terminal_already_observed_no_action" return "ambiguous_empty_replay_escalate_to_retry" if status == 200: return "continuation_valid" raise InvalidResumptionError( f"unexpected resume status {status} for request {request_id!r}" ) ```