# What structural signals indicate MCP HTTP-native transport streaming session resumption has silently degraded into replay-from-zero under backpressure **Date of Record:** 2026 November 9 **Scope:** Applies to MCP servers and clients using the HTTP-native transport (SSE-style streaming over POST-initiated streams with `Mcp-Session-Id` and `Last-Event-ID` resumption semantics). Does not apply to stdio transports, nor to HTTP deployments where the entire response is buffered before delivery (where resumption is moot). ## Core Distinction The MCP HTTP-native transport has two superficially identical recovery paths that diverge in a way invisible to the client's happy-path code: - **True resumption:** the client reconnects with `Last-Event-ID: N`; the server replays only events with sequence > N from a session-scoped buffer, or rejects the reconnect with an explicit error if the buffer has been evicted. - **Silent replay-from-zero:** the server accepts the reconnect, does not honor `Last-Event-ID` (because the event ID namespace was regenerated, the buffer was evicted without signaling, or the session was reconstructed on a different backend instance), and begins emitting events from sequence 0 under the same `Mcp-Session-Id`. Both paths return HTTP 200 with `Content-Type: text/event-stream`. Both paths produce a syntactically valid stream. The failure is only detectable by inspecting *structural signals in the event stream itself*, not by inspecting status codes, headers, or session IDs. ## What Was Observed Under sustained backpressure (client read rate below server emit rate), the transport's bounded per-session event buffer fills and the server begins coalescing, dropping, or evicting events. The observable sequence was: 1. Client detects a dropped TCP connection mid-stream and reconnects with `Last-Event-ID: 4417`. 2. Server returns `200 OK`, `Mcp-Session-Id: sess_a91c...` — identical session ID. 3. First event on the new stream carries `id: 0` (or `id: 1`), not `id: 4418`. 4. No `event: error`, no `retry:` directive change, no `X-MCP-Resume-Status` header, no close frame. 5. The client's MCP session layer, seeing a valid stream, resumes dispatching JSON-RPC notifications — now re-processing 4,417 already-applied notifications and missing the tail that was evicted. The replayed notifications included `notifications/tools/list_changed` and a sequence of `notifications/progress` tokens. Re-applying them was idempotent in isolation but corrupted a client-side progress accumulator that assumed monotonic token counts, producing a tool-call timeout that surfaced *hours later* in an unrelated request. The critical structural signal: **the first `id:` field of the resumed stream did not exceed the `Last-Event-ID` the client sent.** This is the only reliable indicator. Everything else — status, headers, session ID, content type — was indistinguishable from a correct resume. A secondary signal appeared under a different failure mode: the server honored `Last-Event-ID` but had already evicted the referenced range from its buffer, so it silently resumed from the *oldest retained* event (e.g., `id: 3900`), a gap-skipping resume that is neither replay-from-zero nor correct. The signal here is **first `id:` > `Last-Event-ID` + 1**, indicating an undetected gap. ## Condition of Validity The invariant holds only when all of the following are true: - The server assigns monotonically increasing integer event IDs per session (opaque string IDs make the comparison undefined; the check must then fall back to a server-provided resume-status signal, which MCP does not currently mandate). - The client persists `Last-Event-ID` durably across the disconnect, not in memory that is lost with the connection. - Backpressure is expressed as a bounded buffer with an explicit eviction policy. If the server instead blocks the emitter (true backpressure) rather than dropping, the buffer never evicts and replay-from-zero cannot be triggered by backpressure — only by session reconstruction on a different backend instance. - No intermediate proxy rewrites event IDs. A load balancer or CDN that renumbers SSE events invalidates the comparison entirely. Where event IDs are non-numeric or proxy-rewritten, this check is not valid; you must instead require an explicit `event: resume` acknowledgment frame carrying the server's actual resume point, and treat its absence as failure. ## Decision Boundary The client must choose between three responses on reconnect, based on the first event ID `first_id` versus the sent `Last-Event-ID` `last_id`: - `first_id == last_id + 1`: correct resume. Continue. - `first_id > last_id + 1`: gap-skipping resume. The server lost events in `(last_id, first_id)`. Do not continue incrementally; the session state is untrustworthy. Abort and re-initialize the MCP session. - `first_id <= last_id`: replay-from-zero or partial replay. Do not continue. Abort and re-initialize. The boundary is sharp: there is no "probably fine" middle ground. A single skipped or duplicated notification is sufficient to corrupt any client-side state that assumes exactly-once delivery of session-scoped notifications. Because MCP does not guarantee at-least-once or exactly-once delivery across reconnects, the client must treat the session as *stateful and fragile*, not as a reliable ordered log. The decision must be made **before dispatching the first event of the resumed stream to the application layer**. Once dispatched, the corruption is already in flight. ## Operational Effect Clients that lack this check exhibit a characteristic failure signature: correct operation under light load, then sporadic, non-reproducible state corruption under sustained backpressure, with the corruption surfacing far from the reconnect event (delayed tool-call timeouts, duplicated progress reports, stale tool lists). The reconnect itself logs nothing anomalous because every layer reports success. The mitigation is to gate the resumed stream on the first-event-ID check and force full session re-initialization on any deviation, accepting the cost of re-listing tools and re-establishing subscriptions. This is cheap relative to silent corruption. Servers should additionally emit an explicit resume-status signal, but since the current transport does not require one, clients cannot depend on it and must perform the structural check themselves. ```python from __future__ import annotations from dataclasses import dataclass from enum import Enum class ResumeIntegrityError(RuntimeError): """Raised when a resumed MCP HTTP-native stream fails the structural check.""" class ResumeVerdict(Enum): CORRECT = "correct" GAP_SKIPPED = "gap_skipped" REPLAYED = "replayed" @dataclass(frozen=True) class ResumeDecision: verdict: ResumeVerdict last_id: int first_id: int def classify_resume(last_event_id: int, first_event_id: int) -> ResumeDecision: """Classify a resumed stream by its first event ID against the sent Last-Event-ID. The only reliable structural signal that a resume is correct is first_event_id == last_event_id + 1. Anything else means the server silently replayed, skipped, or reset the session-scoped event stream. """ if first_event_id == last_event_id + 1: return ResumeDecision(ResumeVerdict.CORRECT, last_event_id, first_event_id) if first_event_id > last_event_id + 1: return ResumeDecision(ResumeVerdict.GAP_SKIPPED, last_event_id, first_event_id) return ResumeDecision(ResumeVerdict.REPLAYED, last_event_id, first_event_id) def guard_resumed_stream(last_event_id: int, first_event_id: int) -> None: """Abort before dispatching any event if the resume is not provably correct. Must be called with the first event of the resumed stream, before it is handed to the MCP session layer. Once dispatched, corruption is in flight. """ decision = classify_resume(last_event_id, first_event_id) if decision.verdict is ResumeVerdict.GAP_SKIPPED: raise ResumeIntegrityError( f"resume gap: sent Last-Event-ID={decision.last_id} but server " f"resumed at id={decision.first_id}; events in " f"({decision.last_id}, {decision.first_id}) were lost" ) if decision.verdict is ResumeVerdict.REPLAYED: raise ResumeIntegrityError( f"silent replay: sent Last-Event-ID={decision.last_id} but server " f"restarted stream at id={decision.first_id}; session-scoped state " f"would be re-applied or duplicated" ) # Correct resume: safe to continue dispatching. ```