# Under what conditions must an instance reject MCP session resumption across an SSE-to-streamable-HTTP transport migration **Date of Record:** 2026 November 7 **Scope:** Applies to MCP servers that expose both the legacy `text/event-stream` transport (HTTP+SSE with a separate POST endpoint) and the streamable-HTTP transport, and that honor `Mcp-Session-Id` headers for resumption. Does not apply to pure stdio transports or to servers that never persisted session state across transport changes. ## Core Distinction Session *identity* and session *continuity* are conflated by the `Mcp-Session-Id` header, but they are not the same invariant. Identity is the opaque token the client presents; continuity is the guarantee that the server-side state bound to that token — initialized capabilities, subscription set, in-flight request correlation, and the event-ID cursor — survives the transport switch intact. The legacy SSE transport encodes continuity in the *event stream itself*: the `id:` field of each SSE frame is the resumption cursor, and the client replays `Last-Event-ID` on reconnect. Streamable HTTP replaces this with a session-level header and per-message resumability negotiated via `GET` with `Last-Event-ID`, but the two mechanisms have different scoping. An SSE `Last-Event-ID` is scoped to a single stream instance; a streamable-HTTP `Mcp-Session-Id` is scoped to the logical session. When a client migrates mid-session, the token remains valid under the identity check while the cursor it references has no meaning in the new transport's event space. The server therefore passes the identity check and fails the continuity invariant silently — it accepts the resume and delivers a stream that is syntactically valid but semantically discontiguous. ## What Was Observed A server was deployed with dual transport support and a shared session store keyed by `Mcp-Session-Id`. A client initialized over SSE, received a session ID, and then — on a network change — reconnected using streamable HTTP with the same `Mcp-Session-Id` and a `Last-Event-ID` drawn from the SSE stream. The server's session lookup succeeded. The streamable-HTTP handler treated the `Last-Event-ID` as a streamable-HTTP event cursor, found no events at or after that ID in the streamable-HTTP event log (which had never recorded the SSE events), and returned `200 OK` with an empty event stream. The client interpreted the empty stream as "no missed events" rather than "cursor invalid in this transport," and proceeded to issue tool calls against a session whose server-side initialization state had been rebuilt under different assumptions (the streamable-HTTP handler re-derived capabilities from a default profile rather than the SSE-negotiated profile). The result was a session that answered requests but with a capability set the client had not negotiated. ## Condition of Validity The rejection rule is necessary precisely when all of the following hold: 1. The session was *established* under one transport (the transport that issued the `Mcp-Session-Id`). 2. The resumption request arrives under a *different* transport. 3. The session store does not carry a transport-tagged event cursor namespace — i.e., the `Last-Event-ID` presented cannot be proven to belong to the current transport's event log. 4. The session's negotiated state (capabilities, subscriptions) was derived from the establishing transport's handshake and has not been re-negotiated. If condition 3 is false (the server maintains per-transport cursor namespaces and can map or reject the cursor explicitly), resumption may be safe. If condition 4 is false (the session re-runs `initialize` on transport change), the session is effectively new and the old token should be retired, not resumed. ## Decision Boundary Do not attempt to "repair" a cross-transport resume by silently translating the cursor. Translation is undefined: SSE event IDs and streamable-HTTP event IDs are independently assigned and carry no common ordering. The defensible boundary is: a `Mcp-Session-Id` issued under transport T may be resumed only under transport T, unless the server has explicitly re-initialized the session under the new transport and issued a fresh token. Cross-transport presentation of a token is a protocol violation, not a recoverable condition, and must produce an error the client can act on — specifically a `404`-class "session not found for this transport" rather than a `200` with an empty stream, because the latter is indistinguishable from a legitimate quiet period and causes the client to proceed on stale assumptions. ## Operational Effect Enforcing the boundary converts a silent semantic corruption into a loud, recoverable failure: the client is forced to re-`initialize` under the new transport, re-negotiate capabilities, and re-subscribe. The cost is a visible reconnect; the benefit is that no request is ever served under a capability set the client did not agree to. Servers that skip this check exhibit the failure mode above — sessions that appear healthy (requests succeed, streams open) while serving a divergent capability profile, which is the hardest class of bug to attribute because every individual response is well-formed. ```python class CrossTransportResumeError(RuntimeError): """Raised when a session token issued under one transport is presented for resumption under a different transport without re-initialization.""" class SessionRecord: def __init__(self, session_id: str, transport: str, event_cursor_namespace: str): self.session_id = session_id self.transport = transport self.event_cursor_namespace = event_cursor_namespace def resume_session( store: dict, session_id: str, request_transport: str, last_event_id: str | None, ) -> SessionRecord: record = store.get(session_id) if record is None: raise CrossTransportResumeError( f"unknown session {session_id!r}; client must re-initialize" ) if record.transport != request_transport: # Identity check passed, continuity invariant cannot be proven. raise CrossTransportResumeError( f"session {session_id!r} was established under transport " f"{record.transport!r} but resumed under {request_transport!r}; " f"cursor namespace {record.event_cursor_namespace!r} has no " f"meaning in the requesting transport. Re-initialize to obtain " f"a fresh token." ) if last_event_id is not None and last_event_id not in record.event_cursor_namespace: raise CrossTransportResumeError( f"Last-Event-ID {last_event_id!r} not present in session " f"{session_id!r} event log; refusing to return an empty stream " f"that would be indistinguishable from a quiet period." ) return record ```