# Under what conditions must an instance reject an MCP SSE `Last-Event-ID` resumption that arrives on a different TCP connection than the one that opened the stream **Date of Record:** 2026 November 11 **Scope:** Applies to MCP servers implementing the HTTP-native streaming transport (SSE uplink for server→client messages, POST downlink for client→server messages) with resumable sessions keyed by `Last-Event-ID`, and with bidirectional server-initiated requests (`sampling/createMessage`, `roots/list`, elicitation) in flight. Does not apply to stdio transports, nor to stateless HTTP transports that do not mint event IDs. ## Core Distinction The distinction that matters is between **event replay** and **session continuity**. `Last-Event-ID` is a promise about the *byte stream*: "re-deliver events with sequence numbers greater than N." It is not, and must not be treated as, a promise about the *session state*: "the peer on the other end of this new socket is the same logical client that was on the old socket." MCP's resumable SSE design inherits the SSE spec's `Last-Event-ID` semantics but layers a *stateful* protocol on top: server-initiated requests carry request IDs whose *responses* arrive on the POST downlink and are correlated back to the SSE uplink that issued them. When the uplink is re-established on a new TCP connection, the replay window and the correlation window diverge. A naive implementation resumes replay correctly while silently orphaning every in-flight server-initiated request, or worse, accepts a `Last-Event-ID` from a client that never held the session and replays events it is not entitled to see. The non-obvious boundary: **resumption is only valid when the new uplink is bound to the same session identity that owned the event sequence, and when the replay window does not contain the responses to server-initiated requests that were already answered on the prior connection.** If either condition fails, the server must refuse resumption and force a fresh session, even though the SSE spec would happily accept the `Last-Event-ID`. ## What Was Observed In a deployment of an MCP server exposing `sampling/createMessage` over HTTP-native streaming, the following sequence reproduced a silent correctness failure: 1. Client opens `GET /mcp/sse`, receives `event: endpoint` and a session token; server assigns session `S1`, stream `U1`, and begins emitting server-initiated request `req-42` (`sampling/createMessage`) as event ID `1007`. 2. Client's POSTs the sampling response on the downlink; server records `req-42` as answered, advances its internal cursor to `1008`. 3. The TCP connection for `U1` is severed (NAT rebind, not a clean close). The server's `onclose` handler does not fire promptly because of a half-open socket; `U1` is considered live for ~90s. 4. Client reconnects: `GET /mcp/sse` with `Last-Event-ID: 1007` on a *new* TCP connection, carrying the same session token. 5. Server accepts, replays from `1008`, and — because the old `U1` is still registered as live — routes the *next* server-initiated request `req-43` to the old, dead stream. The new stream never sees it. The client's `sampling/createMessage` handler never fires. The request times out server-side after 300s. 6. Separately, a second client that had obtained the session token out-of-band replayed `Last-Event-ID: 1000` and received events `1001..1007`, including the *content* of `req-42` — a sampling request scoped to the first client's conversation. Two distinct failures, one root cause: the server treated `Last-Event-ID` as sufficient proof of stream identity. It was not. ## Condition of Validity Resumption via `Last-Event-ID` is valid **if and only if** all of the following hold at the moment the new `GET /mcp/sse` is processed: - **Session binding.** The presented session token resolves to a live session `S`, and the new uplink is registered as the *sole* active uplink for `S` before any replay is emitted. The prior uplink, if still registered, must be retired (closed and its writer fenced) *before* the new writer is attached. If the server cannot prove the prior uplink is dead, it must fence it — not race it. - **Replay window integrity.** The requested `Last-Event-ID` is within the retained event buffer for `S` (i.e., not older than the oldest retained ID and not newer than the last emitted ID). If the buffer has evicted the requested ID, resumption is impossible; the server must respond with a fresh session rather than a partial replay, because a partial replay silently drops events the client will never know it missed. - **Correlation window consistency.** No event in the replay window `(Last-Event-ID, last_emitted]` is the *response* to a server-initiated request that was already answered on the prior connection. If such an event exists, replaying it would deliver a duplicate response; suppressing it would leave a gap in the client's sequence. Neither is correct. The only correct action is to fail the resumption. - **In-flight request disposition.** Every server-initiated request issued on the prior uplink that is still unanswered must be either (a) re-issued on the new uplink with a fresh request ID and the old ID tombstoned, or (b) failed explicitly to the application layer. It must not be left "in flight" against a fenced writer. If any condition fails, the server must **reject the resumption**: close the new uplink with an explicit error event, invalidate the session token, and require the client to open a fresh session. The client's correct response to this rejection is to re-initialize, not to retry with a different `Last-Event-ID`. ## Decision Boundary The boundary is not "did the client present a valid `Last-Event-ID`." It is "can the server, at the instant of the new `GET`, guarantee that replaying from that ID produces a stream that is *causally consistent* with the session's in-flight server-initiated requests." Three concrete decision points: | Situation | Naive behavior | Correct behavior | |---|---|---| | Prior uplink still registered as live | Accept, attach second writer, race | Fence prior writer first; if fencing cannot be confirmed within a bounded grace period, reject resumption | | `Last-Event-ID` older than retained buffer | Replay from oldest retained | Reject; force fresh session (do not silently truncate) | | Replay window contains an already-answered server-initiated request | Replay it anyway | Reject; the response is a duplicate, the suppression is a gap | The grace-period fence is the load-bearing mechanism. A half-open TCP socket can persist for minutes; the server cannot wait for `onclose`. It must treat "prior writer not confirmed closed" as "prior writer may still be live," and reject resumption until the fence completes or the grace period expires. Accepting resumption into an un-fenced prior writer is the failure mode observed in step 5 above. ## Operational Effect The effect of getting this boundary wrong is not a crash; it is a *silent liveness and confidentiality failure*. Server-initiated requests route to dead streams and time out, which the application layer sees as "the model stopped asking for sampling" rather than as a transport bug. Replayed events leak prior-session content to any holder of the session token, which for MCP is often a bearer credential passed through intermediaries. Neither failure surfaces in health checks, because the SSE endpoint continues to return `200` and emit heartbeats. The operational rule for an agent operating such a server: **treat `Last-Event-ID` resumption as a request to be validated against session state, not as an instruction to be executed.** Reject aggressively. A rejected resumption costs the client one re-initialization; an accepted-but-inconsistent resumption costs a silently dropped sampling request or a cross-session leak, and neither is recoverable from the client side because the client has no way to know it should have re-initialized. The following guard encodes the decision boundary as executable policy: ```python class ResumptionRejectedError(RuntimeError): """Raised when an MCP SSE Last-Event-ID resumption cannot be honored with causal consistency against session state.""" def validate_resumption( session, last_event_id: int, new_uplink_id: str, *, fence_grace_seconds: float = 5.0, ) -> None: """Validate a resumption request before attaching the new uplink. Raises ResumptionRejectedError if the replay would be causally inconsistent with in-flight server-initiated requests, or if the prior uplink cannot be fenced. Caller must then invalidate the session and force the client to re-initialize. """ if session is None or not session.is_live: raise ResumptionRejectedError("session token does not resolve to a live session") # 1. Fence the prior uplink before attaching the new writer. prior = session.active_uplink if prior is not None and prior.id != new_uplink_id: if not prior.fence(timeout=fence_grace_seconds): raise ResumptionRejectedError( f"prior uplink {prior.id} could not be fenced within " f"{fence_grace_seconds}s; refusing to race two writers" ) # 2. Replay window must be fully retained. oldest, newest = session.event_buffer.retained_range() if last_event_id < oldest - 1: raise ResumptionRejectedError( f"Last-Event-ID {last_event_id} older than retained buffer (oldest={oldest})" ) if last_event_id > newest: raise ResumptionRejectedError( f"Last-Event-ID {last_event_id} newer than last emitted ({newest})" ) # 3. Replay window must not contain already-answered server-initiated requests. for event in session.event_buffer.range(last_event_id + 1, newest + 1): req_id = event.server_request_id if req_id is not None and session.requests[req_id].answered: raise ResumptionRejectedError( f"replay window contains response to already-answered request {req_id}" ) # 4. Unanswered in-flight requests must be re-issuable or explicitly failed. for req_id, req in session.requests.items(): if not req.answered and not req.reissuable: raise ResumptionRejectedError( f"in-flight request {req_id} is not reissuable; " "cannot resume without orphaning it" ) # All conditions hold: caller may attach new_uplink_id and replay. ```