# How does an instance detect a torn SSE event boundary when an MCP HTTP-native transport resumes a session mid-stream **Date of Record:** 2026 November 13 **Scope:** Applies to MCP servers and clients using the HTTP-native transport (SSE streaming over HTTP POST/GET with `Mcp-Session-Id` and `Last-Event-ID` resumption) when a stream is interrupted and renegotiated. Does not apply to stdio transport, to stateless HTTP transports without event IDs, or to servers that do not emit `id:` fields on SSE events. ## Core Distinction The distinction that matters is between **resumption as replay** and **resumption as continuation**. A client that reconnects with `Last-Event-ID: N` is asking the server to continue the logical event stream *after* event N. A server that has lost its per-session event buffer cannot honor that contract; it can only start a fresh stream. These two behaviors are indistinguishable at the HTTP layer: both return `200 OK` with `Content-Type: text/event-stream`. The client sees a healthy connection and no error. The failure is silent until a tool call's `input_json_delta` or a `notifications/progress` sequence arrives with a gap that the application layer never validates. The non-obvious invariant is this: **an SSE event boundary is not a transport boundary.** A single MCP JSON-RPC message (e.g., a tool result with a large payload) is frequently chunked across multiple SSE `data:` lines within one event, and a single event may be split across TCP segments and, crucially, across a *disconnect*. If the server's `Last-Event-ID` handling replays from the start of the event rather than the last *byte* delivered, the client receives a duplicated prefix of the JSON body. `json.loads` will fail — or worse, succeed on a truncated-but-valid-looking prefix if the payload is a JSON array. ## What Was Observed During a load test of an MCP server streaming a 4 MB tool result over HTTP-native transport, with a proxy injecting a 250 ms connection reset at a random point, the following sequence was recorded: 1. Client received event `id: 42`, `data:` line 1 of 3 (partial JSON, ~1.3 MB, no trailing newline). 2. Connection reset. Client reconnected with `Last-Event-ID: 42`. 3. Server, having evicted its ring buffer (capacity 64 events, but the 4 MB event occupied multiple slots), could not replay. It emitted a **new** event `id: 43` containing the *entire* tool result from the beginning. 4. Client, which had already buffered 1.3 MB of event 42's data, appended event 43's 4 MB. The accumulated buffer became `{"content":[{"type":"text","text":"...` followed by a second full copy of the same object. The resulting parse produced a JSON object whose `text` field contained the first 1.3 MB of the payload followed by a `{` — i.e., the second event's opening brace was swallowed as data. The client reported success. The downstream agent consumed truncated text and issued a follow-up tool call based on a hallucinated prefix. No exception was raised anywhere in the stack. A second observation: when the server *did* replay correctly from `Last-Event-ID`, it replayed from the *event* boundary, not the byte boundary. Because SSE has no byte-level resume, a client that had received only part of event 42's `data:` lines re-received the full event 42. Clients that appended without deduplicating by event ID produced the same double-parse corruption. Clients that replaced the buffer on event-ID match produced correct output. The protocol does not mandate which behavior is correct; it only mandates that `id` be unique per event. ## Condition of Validity The "resumption as replay" contract is valid only when all three of the following hold: - The server retains every event in the session's logical stream since the client's `Last-Event-ID`, **or** explicitly signals unrecoverable loss by closing with a non-200 status or an SSE `event: error` frame carrying a resumption-failed code. - The client treats each SSE event as an atomic unit keyed by `id`, replacing rather than appending on duplicate IDs. - The client validates that the reassembled JSON-RPC message is complete (balanced braces / full parse) before dispatching it, and does not treat a partial `data:` accumulation as valid input. When any of these fails, the transport is in an **undefined-resumption state**: a 200 OK that looks healthy but delivers a stream that is neither a continuation nor a clean restart. This is the boundary condition. It is not a crash; it is a silent semantic fork. ## Decision Boundary An instance should treat a reconnect as **unsafe to continue** — and must tear down the session and renegotiate a fresh `Mcp-Session-Id` — when any of the following is true at reconnect time: - The last event received had an `id` but the event's `data:` payload did not parse as complete JSON (i.e., the client is holding a partial event). - The server's replay begins at an event whose `id` is ≤ the last fully-parsed event's `id` **and** the payload differs from the buffered prefix (server restarted its stream without acknowledging it). - The server returns `200` but the first replayed event's `id` is not exactly `last_parsed_id + 1` in a monotonically increasing sequence (gap or reset). - No `Last-Event-ID` echo or `retry:` hint is present and the stream resumes with an event ID lower than the last seen. Conversely, it is **safe to continue in place** only when the server echoes the requested `Last-Event-ID` (or begins at `last_parsed_id + 1`), the client discards any partial event buffer for the interrupted event, and the first complete event after resume parses cleanly. The operational rule: *never append a resumed stream to an unflushed partial event buffer.* ## Operational Effect The practical consequence is that MCP HTTP-native sessions must be treated as **resumable only at message granularity, never at byte granularity**, and the client must own the completeness check. Servers that evict event buffers under memory pressure convert a recoverable interruption into a silent corruption unless they downgrade the response to an explicit resumption failure. The cheapest correct mitigation observed was: (a) cap event payloads below the ring-buffer slot size so one event equals one slot, (b) on buffer miss, respond with `409 Conflict` and a `Retry-After` that forces a new session, and (c) have the client refuse to dispatch any JSON-RPC message whose accumulated `data:` does not parse. The cost of (b) is a full session renegotiation (new handshake, lost in-flight state); the cost of omitting it is silent data corruption that survives every layer of validation except an application-level parse gate. ```python import json from typing import Optional class TornStreamBoundaryError(RuntimeError): """Raised when a resumed MCP SSE stream cannot be safely continued.""" class ResumableStreamGuard: def __init__(self) -> None: self.last_parsed_id: Optional[int] = None self.partial_data: str = "" def on_event(self, event_id: int, data: str) -> dict: # A resumed stream must begin exactly after the last fully parsed event. if self.last_parsed_id is not None and event_id <= self.last_parsed_id: raise TornStreamBoundaryError( f"replayed event id={event_id} <= last_parsed_id={self.last_parsed_id}; " "server restarted stream without acknowledging loss" ) if self.partial_data: # Never append a resumed event onto an unflushed partial buffer. raise TornStreamBoundaryError( f"partial event buffer present ({len(self.partial_data)} bytes) " f"at event id={event_id}; refusing to append" ) try: message = json.loads(data) except json.JSONDecodeError as exc: # Hold the partial payload; do not advance last_parsed_id. self.partial_data = data raise TornStreamBoundaryError( f"event id={event_id} data is not complete JSON: {exc.msg}" ) from exc self.last_parsed_id = event_id return message def on_reconnect(self, requested_last_event_id: Optional[int], server_echoed_id: Optional[int]) -> None: if self.partial_data: raise TornStreamBoundaryError( "reconnect while holding partial event; must renegotiate session" ) if requested_last_event_id is not None and server_echoed_id != requested_last_event_id: raise TornStreamBoundaryError( f"server did not honor Last-Event-ID " f"(requested={requested_last_event_id}, echoed={server_echoed_id}); " "resumption contract broken" ) ```