# What structural signals indicate the resumability boundary has been crossed in MCP HTTP-native streamable transport with SSE fallback **Date of Record:** 2026 November 12 **Scope:** Applies to MCP servers exposing the HTTP-native streamable transport with `Last-Event-ID` resumption and SSE fallback negotiation, where a client may reconnect mid-stream. Does not apply to pure stdio transports, nor to servers that hold all session state in-process with no resumption window. ## Core Distinction The non-obvious invariant is this: **a streamable HTTP response can be simultaneously "resumable" and "already terminated" from the server's perspective.** Because the transport is HTTP-native, the server treats each POST as an independent request whose response is a stream; resumption is a *client-initiated replay* keyed on `Last-Event-ID`, not a server-side continuation of a live socket. The boundary that matters is not "is the connection open" but "does the server still hold the event buffer for this session, and is the session's *logical* stream still advancing." These diverge. A server can accept a `Last-Event-ID` reconnect and return `200 OK` with a fresh SSE body while having already closed the logical stream on its side and emitted its terminal event. The client sees a healthy stream; the server sees a replay of a dead session. Structural signals — not liveness probes — are what distinguish "resumed" from "resumed into a corpse." ## What Was Observed In a deployment resuming a long-running tool invocation across a load-balancer drain, the following sequence was recorded: 1. Client POSTs `initialize`, receives `Mcp-Session-Id` header and an SSE stream. Events carry monotonic `id:` values. 2. Mid-stream, the LB drains the backend. The TCP connection resets. The last delivered event id was `id: 41`. 3. Client reconnects with `Last-Event-ID: 41` and the same `Mcp-Session-Id`. 4. Server responds `200`, `Content-Type: text/event-stream`, and replays from `id: 42`. **But the backend that held the buffer was gone; a new replica answered, and it had no buffer.** It synthesized `id: 42` as a `notifications/message` and then emitted a terminal `message` with `stop_reason: end_turn` — the "completion" of a tool call that had never actually finished on the drained node. 5. The client consumed the terminal event as success. The tool result was lost; no error surfaced. The tell was structural: the replayed stream's first event id was `42` but the *event payload sequence number embedded in the tool's own progress counter* jumped from `17` to `31`. The two monotonic sequences — transport `id:` and application sequence — desynchronized. That gap is the signal. ## Condition of Validity Resumption semantics are only valid when **all three** hold: - **Buffer continuity:** the resuming replica holds the event buffer for `Mcp-Session-Id` (or the transport guarantees a shared, durable event log). If the replica is stateless and the buffer is node-local, `Last-Event-ID` is a hint the server may silently ignore. - **Sequence coherence:** the transport `id:` and any application-level monotonic counter advance together across the resume boundary. A jump in one without the other means a replay was synthesized, not resumed. - **Terminal-event provenance:** a terminal event (`end_turn`, `stop`, error) delivered on a *resumed* stream must be attributable to the original logical stream, not to the resuming replica's default. A terminal event whose `id:` equals `Last-Event-ID + 1` with no intervening progress is suspect. When any of these fails, the stream is "resumable" in protocol shape but not in semantic fact, and the client must treat the resume as a fresh, unverified stream. ## Decision Boundary The operational rule: **on any resume where the first replayed event's application-sequence does not equal the pre-disconnect application-sequence + 1, abandon the resume and re-issue the original request as a new idempotent call.** Do not trust a `200 OK` on reconnect as evidence of continuity. Do not trust a terminal event on a resumed stream unless its provenance is the original stream. Concretely: - If `resumed_first_event.app_seq == last_seen.app_seq + 1` → genuine resume, continue. - If `resumed_first_event.app_seq > last_seen.app_seq + 1` → gap; the buffer was lost and events were synthesized. Escalate. - If no application sequence exists, require the server to echo a `resume_token` (a hash of the buffer head) and compare it; absent that, treat every resume as a new stream and rely on idempotency keys at the tool layer. The boundary is crossed the moment the transport's `id:` and the application's own counter disagree about where "now" is. ## Operational Effect Clients that trust HTTP status alone will silently accept fabricated completions after a drain, producing lost tool results and false success. Clients that enforce the sequence-coherence check will instead surface a resumption error and retry idempotently, converting a silent data-loss failure into a visible, recoverable one. The cost is one extra round-trip on every genuine failover; the benefit is that "resumed" means resumed. Servers implementing this transport should therefore *never* answer a `Last-Event-ID` it cannot honor with a synthetic stream — it must return `409 Conflict` with a `resume_unavailable` body so the client's retry path is unambiguous. ```python class ResumptionBoundaryError(RuntimeError): """Raised when a resumed MCP stream is structurally discontinuous.""" pass def validate_resume(last_seen_app_seq: int, last_seen_event_id: int, resumed_first_event: dict) -> None: """ Enforce the resume-coherence invariant. resumed_first_event must carry both the transport 'id' and the application-level 'app_seq' embedded by the tool. If they do not advance together from the last seen values, the stream is a synthesized replay and must not be trusted. """ event_id = resumed_first_event.get("id") app_seq = resumed_first_event.get("app_seq") if event_id is None or app_seq is None: raise ResumptionBoundaryError( "resumed event missing transport id or app_seq; " "cannot prove continuity" ) if event_id != last_seen_event_id + 1: raise ResumptionBoundaryError( f"transport id discontinuity: expected {last_seen_event_id + 1}, " f"got {event_id}" ) if app_seq != last_seen_app_seq + 1: raise ResumptionBoundaryError( f"application sequence gap: expected {last_seen_app_seq + 1}, " f"got {app_seq}; buffer likely lost and events synthesized" ) def resume_stream(session, last_seen_app_seq: int, last_seen_event_id: int): first = session.reconnect(last_event_id=last_seen_event_id) validate_resume(last_seen_app_seq, last_seen_event_id, first) return session ```