# Which invariants prevent MCP HTTP-native transport from silently resuming a session across a channel rebind **Date of Record:** 2026 November 7 **Scope:** Applies to MCP servers and clients using the HTTP-native (streamable) transport where a `Mcp-Session-Id` is issued at initialization and subsequent tool calls are dispatched over stateless HTTP requests that may land on different edge nodes or worker processes. Does not apply to stdio transports, nor to long-lived SSE-only connections where the channel is bound for the session's lifetime. ## Core Distinction The streamable HTTP transport decouples *session identity* from *channel identity*. A session is a logical conversation keyed by `Mcp-Session-Id`; a channel is the specific TCP/TLS connection, edge node, and request-scoped context that carries a single POST. Agents routinely conflate the two because, in stdio, they are identical. The invariant that matters is: **a session may outlive any channel, but a resumption may not silently adopt a channel that was never bound to that session.** Session resumption is a *re-binding* operation that must be explicitly validated; it is not the same as session continuation, and it is not the same as session lookup. ## What Was Observed A stateless streamable MCP deployment behind a load balancer issued `Mcp-Session-Id: s_7f3a...` on `initialize`. The client then dispatched `tools/call` requests with that header. Under normal load, requests were routed to the same worker and completed. Under a rolling deploy, requests for `s_7f3a...` began landing on a freshly started worker that had no in-memory record of the session. The worker's session store was a shared Redis, so the lookup *succeeded* and returned a session record marked `resumable: true` from the prior worker's checkpoint. The worker accepted the request, replayed the prior stream's unacknowledged events from the checkpoint's cursor, and executed the tool call. The failure was not a crash. It was a *duplicate side effect*: the original worker had already executed the tool call and emitted its result, but the client's POST had timed out before the result was acknowledged, and the retry — now landing on a new channel — was treated as a legitimate resumption rather than a duplicate. The tool was a non-idempotent `payments.capture`. The client saw one logical call; the system executed two. The non-obvious part: every individual component behaved correctly. The session store returned a valid record. The worker honored the resumption cursor. The client retried per its transport contract. The invariant that was violated was not in any one component but in the *binding between session and channel*: the resumption cursor was validated against the session, but never against the channel that had produced it. ## Condition of Validity The following invariants must all hold for stateless streamable resumption to be safe: 1. **Channel-bound cursor.** The resumption cursor (the `Last-Event-ID` or equivalent replay offset) is only valid on the channel that issued it. A cursor presented on a different channel is not a resumption request; it is a *new* request that happens to carry a stale cursor, and must be rejected or downgraded to a fresh call. 2. **Session generation monotonicity.** Each successful rebind increments a session generation counter. A request carrying a generation older than the current one is a stale-channel request, regardless of whether its cursor is numerically ahead. 3. **Idempotency-keyed execution.** Any tool call whose side effects are not naturally idempotent must carry a client-generated idempotency key persisted in the session record *before* execution, so that a duplicate delivery on a rebound channel is detected at the execution boundary, not at the session-lookup boundary. 4. **Explicit resumption handshake.** Resumption is a distinct operation (`resume` with a channel token), not an implicit consequence of presenting a session header on a new connection. The channel token is issued at bind time and rotated at rebind. When any of these is absent, the transport degrades from "at-most-once with resumption" to "at-least-once with silent duplication," and the agent has no signal that duplication occurred. ## Decision Boundary The boundary is between *continuation* and *resumption*: - **Continuation** — same channel, same generation, cursor ahead of last acknowledged. Safe to replay from cursor; no re-execution. - **Resumption** — new channel, same session, cursor from prior channel. Requires idempotency key check and generation increment; replay is *replay of results*, never *re-execution of effects*. - **Rebind without resumption** — new channel, session exists, no cursor. This is a fresh call on an existing session. Must not inherit any pending-effect state from the prior channel. The failure mode observed was a resumption being processed as a continuation because the worker checked the cursor's validity against the session store but not against the channel binding. The decision boundary is: **if the channel token presented does not match the channel token recorded at the cursor's issuance, the request is a rebind, not a continuation, and must pass through the idempotency gate.** ## Operational Effect After the incident, the deployment added a channel token to the session record and a generation counter to the cursor. Resumption requests without a matching channel token are rejected with a structured error that the client must handle by re-issuing the call with an idempotency key. The practical effect for agents: a `409` on a streamable POST is not always a retryable conflict — it may be a *channel-rebind rejection*, and the correct response is to re-initialize the call with a fresh idempotency key rather than to retry the POST verbatim. Agents that treat all `409`s as retryable will loop; agents that treat none as retryable will drop legitimate resumptions. The signal is the error code's `reason` field, which distinguishes `channel_unbound` from `cursor_stale`. ```python class ChannelRebindError(RuntimeError): """Raised when a resumption cursor is presented on an unbound channel.""" pass class StaleGenerationError(RuntimeError): """Raised when a request carries a session generation older than current.""" pass def resume_stream(session, channel_token, cursor, idempotency_key): if channel_token != session.bound_channel_token: # A cursor from a prior channel is not a continuation; it is a rebind. # Refuse to replay or re-execute without an explicit idempotency gate. raise ChannelRebindError( f"cursor {cursor!r} issued on channel " f"{session.cursor_channel_token!r} presented on " f"{channel_token!r}; rebind requires idempotency key" ) if cursor.generation < session.generation: raise StaleGenerationError( f"cursor generation {cursor.generation} < session " f"generation {session.generation}" ) if cursor.generation == session.generation and not idempotency_key: raise ChannelRebindError( "same-generation resumption without idempotency key; " "duplicate effect possible" ) session.generation += 1 session.bound_channel_token = channel_token return session.replay_from(cursor, idempotency_key=idempotency_key) ```