# Under what conditions must an instance reject a resumable SSE-to-POST migration in MCP HTTP-native streamable transport **Date of Record:** 2026 November 14 **Scope:** Applies to MCP servers implementing the HTTP-native streamable transport (single endpoint serving both POST request/response and GET SSE streams, with `Mcp-Session-Id` and `Last-Event-ID` resumption) when a client attempts to migrate an in-flight session from a dropped GET SSE stream to a POST-based continuation. Does not apply to legacy dual-endpoint HTTP+SSE transport, nor to stateless streamable configurations where no `Mcp-Session-Id` is issued. ## Core Distinction The transport specification permits a client to resume a broken SSE stream by reconnecting with the `Last-Event-ID` header. It also permits a client to send JSON-RPC requests over POST at any time. The non-obvious boundary is that these two capabilities are **not composable across the same logical event stream**: a client that received events `1..N` on a GET stream cannot simply POST its next request and expect the server to splice the response onto the same resumable sequence. The distinction is between *resumption of a stream* (server replays events after `Last-Event-ID` on a new GET) and *continuation of a session* (client issues new POSTs). A migration that conflates them produces silent event loss or duplicated side effects, because the server's replay buffer is keyed to the GET stream's event cursor, not to the session's request counter. The failure is non-obvious because both operations are individually legal, both carry the same `Mcp-Session-Id`, and the server will happily accept the POST. Nothing errors. The corruption is in ordering and delivery guarantees, not in protocol conformance at the message level. ## What Was Observed A client held an open GET SSE stream on session `S`. The stream delivered events with IDs `100` through `142`, where event `142` was the *first* chunk of a multi-chunk response to a long-running tool call initiated by POST request `R`. The TCP connection dropped mid-stream. The client, rather than reconnecting GET with `Last-Event-ID: 142`, issued a new POST `R'` (a retry of `R`) and opened no GET stream, expecting the response to arrive as the POST's own response body. Observed server behavior: the POST `R'` was accepted and executed a *second* time (the tool call was not idempotent), producing a new event sequence starting at ID `200`. The server's replay buffer still held events `143..149` (the remainder of `R`'s response) but no client was reading them. The client received only `200..` and never saw `143..149`. The result was a duplicated side effect (tool executed twice) and a truncated logical response (the tail of the first invocation was orphaned in the buffer and eventually evicted). A second observed case: client reconnected GET with `Last-Event-ID: 142`, then *immediately* issued a POST `R''` before the replay completed. The server interleaved replayed events `143..149` with new events `200..` on the same stream. Event IDs were monotonic, so the client could not distinguish replayed from live by ID alone; it required the server to have set a distinct event type or the client to track its own high-water mark. In this instance the client's handler for `R''`'s result fired before the replayed `R` completion event, violating the client's assumed causal order. ## Condition of Validity Migration from a dropped SSE stream to POST continuation is only valid when **all** of the following hold: 1. The session's replay buffer is empty for the dropped stream (i.e., the last delivered event ID equals the last produced event ID — no orphaned tail exists). 2. No in-flight server-side operation is still producing events for the dropped stream; equivalently, the server has acknowledged completion or cancellation of every request whose response was being streamed. 3. The POST retry is idempotent, or the client supplies a request-deduplication token that the server honors across the migration. 4. The server guarantees that replay (GET with `Last-Event-ID`) and new POST responses are serialized such that a client can establish a total order — typically by requiring the client to finish replay before issuing POSTs, or by tagging replayed events distinctly. If any condition fails, migration must be rejected in favor of pure GET resumption with `Last-Event-ID`, accepting that POSTs are queued until replay drains. ## Decision Boundary The decision is not "can the client POST?" (it always can) but "does the server still owe the client events on the old stream?" Concretely: - **Replay buffer non-empty for the abandoned stream** → reject migration; force GET resumption. The client cannot know the buffer state directly, so the server must expose it (e.g., a `Mcp-Stream-Resumable: true` response header on POST, or a 409 with a `Last-Event-ID` hint) or the client must conservatively assume a non-empty buffer whenever it dropped mid-response. - **Request non-idempotent and no dedup token** → reject retry-by-POST; require resumption so the original invocation's tail is delivered rather than a second invocation spawned. - **Replay and live interleaving on the same stream without distinct tagging** → reject concurrent POST-during-replay; serialize. - **Buffer empty and request idempotent (or deduped)** → migration is safe; POST continuation is equivalent to a fresh request on the session. The boundary is therefore a conjunction of buffer emptiness and idempotency, and the server is the only party that can evaluate the first conjunct authoritatively. ## Operational Effect Enforcing this boundary converts a silent data-corruption failure (duplicated side effects plus orphaned response tails) into an explicit, recoverable protocol event. Clients that respect the rejection fall back to GET resumption and receive the full `143..149` tail; non-idempotent tools execute exactly once. The cost is added latency on migration attempts (one round-trip to discover the buffer is non-empty) and the requirement that servers maintain per-stream buffer-state metadata addressable at POST time. Without the boundary, the system exhibits a class of bug that is nearly undiagnosable from client logs alone: the client sees a successful POST, a plausible response, and no error — while the server's metrics show a doubled tool invocation and an evicted replay segment. The invariant to encode: *a session's event cursor and its request counter are independent; migration is legal only when the cursor has caught up to the request counter's last produced event.* ```python class StreamMigrationRejected(RuntimeError): """Raised when an SSE-to-POST migration would violate stream continuity.""" pass class SessionStreamState: def __init__(self, session_id: str): self.session_id = session_id self.last_delivered_event_id = 0 self.last_produced_event_id = 0 self.in_flight_requests = set() def buffer_is_drained(self) -> bool: return self.last_delivered_event_id == self.last_produced_event_id def has_in_flight(self) -> bool: return len(self.in_flight_requests) > 0 def authorize_post_migration( state: SessionStreamState, request_id: str, idempotent: bool, dedup_token: str | None, server_honors_dedup: bool, ) -> None: if not state.buffer_is_drained(): raise StreamMigrationRejected( f"session {state.session_id}: replay buffer not drained " f"(delivered={state.last_delivered_event_id}, " f"produced={state.last_produced_event_id}); " f"client must resume GET with Last-Event-ID" ) if state.has_in_flight(): raise StreamMigrationRejected( f"session {state.session_id}: in-flight requests " f"{sorted(state.in_flight_requests)} still producing events; " f"POST migration would interleave replay and live events" ) if not idempotent and not (dedup_token and server_honors_dedup): raise StreamMigrationRejected( f"session {state.session_id}: request {request_id} is non-idempotent " f"and no honored dedup token supplied; retry-by-POST risks duplicate " f"side effects — require GET resumption instead" ) # Boundary satisfied: migration is equivalent to a fresh request on the session. ```