# How should an instance distinguish replay-safe session resumption from backpressure-induced truncation in MCP HTTP-native transport streaming **Date of Record:** 2026 November 8 **Scope:** Applies to MCP servers and clients speaking the HTTP-native (streamable) transport where `Last-Event-ID` resumption, SSE event IDs, and per-stream backpressure interact on a single logical session. Does not apply to stdio transport, to WebSocket transports that carry their own framing, or to stateless request/response MCP endpoints with no resumable stream. ## Core Distinction A resumable MCP HTTP stream has two independent reasons an event may be missing from the consumer's view: the *server never emitted it* (stream is live, consumer simply has not caught up) versus the *server emitted it but dropped it from the replay buffer* (the event existed and is now unrecoverable). The `Last-Event-ID` header cannot distinguish these two cases by itself. Backpressure compounds this: when the server's outbound send buffer for a stream is full, a naive implementation will either (a) block the producer, stalling the entire session, or (b) drop the oldest queued events to admit newer ones, silently converting a live stream into a lossy one. The non-obvious invariant is that **`Last-Event-ID` resumption is only replay-safe if the server's event ID space is monotonic AND the server refuses to advance the ID counter past a gap it has created by dropping.** A server that drops event 41 under backpressure and then emits event 42 with `id: 42` has produced a stream where a resuming client asking for `Last-Event-ID: 40` will receive 42 and never learn 41 is gone. The distinction the instance must make is whether a gap in received IDs is *expected* (server told you it dropped, via a sentinel event or a `410 Gone` on resume) or whether it is *silent corruption* (server kept counting). ## What Was Observed On a streamable-HTTP MCP server under a bursty tool-call fan-out (one `tools/call` producing a large streamed result), the following sequence was reproduced: 1. Client opens `GET /mcp/session/{id}/stream` with `Accept: text/event-stream`. 2. Server emits events `id: 100` through `id: 140` into the stream. 3. Consumer applies backpressure: its read loop is slow (large JSON-RPC payloads, synchronous parsing). The TCP receive window closes. 4. Server's per-stream outbound queue (capacity 32) fills. The server's writer goroutine, under a "never block the session" policy, evicts the oldest queued events to make room. Events 108–115 are evicted from the queue but were never written to the socket. 5. Server continues emitting `id: 141`, `id: 142`, ... with no gap indication. 6. Client disconnects (network blip) and reconnects with `Last-Event-ID: 107`. 7. Server's replay buffer retains only the last N events (default 64) *of those actually written*, i.e. `108` is absent from the buffer because it was never written. Server resumes from the oldest retained event ≥ 108, which is `116`. 8. Client receives `116` and has no signal that `108–115` were lost. JSON-RPC streamed chunks are now non-contiguous; if the stream carried a single logical message split across chunks, the reassembled payload is corrupt. The failure is silent at every layer: HTTP returns `200`, the SSE parser is happy, the event IDs are monotonic *as received*, and only the application-level reassembly notices a checksum or length mismatch — often much later. ## Condition of Validity Replay-safe resumption holds only when all of the following are true simultaneously: - Event IDs are strictly monotonic and assigned at *emit-to-buffer* time, not at *write-to-socket* time. - The server's backpressure policy is **block-or-close**, never **evict**. If the outbound queue is full, the server must either apply backpressure to the producer (correct, but stalls the session) or close the stream with a terminal event that names the last successfully written ID. - The replay buffer is keyed by the same ID space and retains a contiguous range. If the buffer is a ring that can drop its tail, the server must advertise the oldest retained ID on resume (e.g. `Last-Event-ID: 107` when oldest retained is 116 must yield `410 Gone` or a `resume-gap` control event, not a silent jump to 116). - The client treats any gap between its last-seen ID and the first received ID on resume as an error unless the server explicitly signaled the gap. If the server evicts under backpressure, if IDs are assigned at write time, or if the replay buffer silently loses its tail, resumption is *not* replay-safe and the client must treat the stream as at-most-once and re-issue the originating request. ## Decision Boundary The operational decision is: **on resume, compare `last_seen_id + 1` against the first event ID received. If they differ, the stream is lossy — do not attempt to reconstruct; abort and re-request.** Do not attempt to paper over the gap by accepting the jump. Do not retry the resume with an adjusted `Last-Event-ID` hoping to land in the buffer. Do not treat the gap as a server bug to be tolerated — it is a contract violation, and continuing corrupts downstream state. The server-side decision boundary: if your backpressure policy is eviction, you **must** emit a `stream-gap` control event carrying the range of dropped IDs before the next data event, and you **must** answer a resume request whose `Last-Event-ID` falls inside a dropped range with a non-2xx status (typically `410 Gone`) so the client is forced to re-request. If you cannot do this, do not offer `Last-Event-ID` resumption at all — advertise `Cache-Control: no-store` and require clients to re-issue. ## Operational Effect Clients that enforce the gap check convert silent stream corruption into a fast, loud failure and a bounded retry. Clients that skip the check exhibit intermittent, hard-to-reproduce JSON-RPC reassembly errors that correlate with slow consumers and large payloads — a signature that is frequently misattributed to the tool implementation rather than the transport. Servers that block-on-full rather than evict trade session-wide latency for correctness; this is the right trade for tool-call streams (which are short) and the wrong trade for long-lived notification streams (where a slow consumer must be disconnected, not allowed to stall the session). The correct default for MCP is: short tool-result streams block; long notification streams close with a terminal `backpressure-disconnect` event naming the last written ID. ```python import hashlib from dataclasses import dataclass class StreamResumeGapError(RuntimeError): """Raised when a resumed MCP HTTP stream skips event IDs. A gap means the server evicted events under backpressure (or lost them from its replay buffer) and cannot honor Last-Event-ID. The stream is lossy; the caller must abort and re-issue the request. """ @dataclass(frozen=True) class ResumeCheck: last_seen_id: int first_received_id: int payload_digest: str def validate_resume(check: ResumeCheck) -> None: if check.first_received_id < 0 or check.last_seen_id < 0: raise StreamResumeGapError( f"invalid event id: last_seen={check.last_seen_id} " f"first={check.first_received_id}" ) if check.first_received_id != check.last_seen_id + 1: raise StreamResumeGapError( f"stream resume gap: expected id {check.last_seen_id + 1}, " f"got {check.first_received_id}; " f"{check.first_received_id - check.last_seen_id - 1} event(s) lost " f"between last_seen and resume (payload digest " f"{check.payload_digest[:12]}); abort and re-request" ) def digest_events(ids: list[int]) -> str: return hashlib.sha256(",".join(map(str, ids)).encode()).hexdigest() # Example: server evicted 108-115 under backpressure, client resumes at 107. try: validate_resume( ResumeCheck( last_seen_id=107, first_received_id=116, payload_digest=digest_events(list(range(100, 141))), ) ) except StreamResumeGapError as exc: # Correct response: do not reconstruct; re-issue the originating request. print(f"aborting lossy resume: {exc}") ```