# Why does an instance lose tool-call state exactly once after an SSE-to-streamable-HTTP migration? **Date of Record:** 2026 November 12 **Scope:** Applies to MCP servers that expose the same logical endpoint under both the legacy HTTP+SSE transport and the streamable-HTTP transport during a migration window, where clients may resume sessions via `Last-Event-ID` or `Mcp-Session-Id`. Does not apply to pure stdio transports or to deployments that hard-cut over to streamable-HTTP with no dual-listener window. ## Core Distinction The non-obvious boundary is that **session identity and event-stream identity are two different things**, and the migration window conflates them. In the legacy SSE transport, a session is implicitly the lifetime of one long-lived `GET` stream; the `Last-Event-ID` header is the only resumption primitive, and it is scoped to that single stream. In streamable-HTTP, a session is an explicit `Mcp-Session-Id` that outlives any individual request, and resumption is expected to work across POST-response streams and the optional standalone `GET` stream *within the same session*. The failure invariant: **a client that resumes with a valid `Mcp-Session-Id` but a `Last-Event-ID` minted by the legacy SSE listener will be accepted, not rejected — and it will silently skip the tool-call result it was waiting for.** The server validates the session, finds the event ID "plausible" because both listeners share an event-ID namespace, and replays from a cursor that points into a stream the client never actually consumed. The result is a single, unrepeatable loss of exactly one tool-call outcome: the resumed stream begins *after* the event the client needed, so the client never sees it, and because the event was already acknowledged as delivered server-side, it is never re-sent. ## What Was Observed During a staged migration, a server ran both listeners behind one router. A client opened a legacy SSE stream, received events `e-100` through `e-104` (the last being a `tools/call` result for `search_index`), and then the SSE connection was severed by an idle proxy timeout before the client's read loop drained `e-104`. The client reconnected using the streamable-HTTP endpoint, sending `Mcp-Session-Id: s-77` and `Last-Event-ID: e-104`. The server's resumption handler did three things in order: 1. Looked up `s-77` — valid, because the migration shim had bridged the legacy session into the new session store. 2. Parsed `e-104` — valid format, because the event-ID allocator was shared across both listeners. 3. Replayed events with ID *strictly greater than* `e-104`. Because `e-104` was the tool-call result itself, "strictly greater than" meant "everything except the result." The client received `e-105` (a `notifications/progress` for an unrelated request), concluded the stream was healthy, and then blocked indefinitely waiting for a `tools/call` response that had already been consumed by the resume cursor. No error was raised on either side. The client's request timeout eventually fired, and it retried the tool call — producing a duplicate side effect on the backing index. The signature is distinctive: **exactly one missing response, on the first resume after migration, with the resume cursor equal to the ID of the missing event.** Subsequent resumes work correctly because by then the client is consuming streamable-HTTP-minted event IDs that were never acknowledged by the legacy listener. ## Condition of Validity This failure requires all of the following to hold simultaneously: - A dual-listener window where legacy SSE and streamable-HTTP share an event-ID allocator (monotonic or otherwise) rather than namespacing by transport. - Session bridging that accepts a legacy session ID at the streamable-HTTP endpoint. - A resume cursor whose value equals the ID of an event the client has *not* fully processed — i.e., the disconnect happened after server-side send but before client-side drain. This is the common case under proxy idle timeouts, not the rare one. - Replay semantics of "strictly greater than cursor" rather than "greater than or equal to, with at-least-once delivery and client-side dedup." If any one of these is absent, the failure does not manifest. In particular, if the event-ID namespace is partitioned by transport (e.g., `sse:e-104` vs `http:e-104`), the resume handler rejects the cross-transport cursor and the client falls back to a full re-initialization, which is slow but correct. ## Decision Boundary The operational choice is between two resumption contracts, and the migration must pick one explicitly rather than inheriting whichever the legacy code happened to implement: - **At-most-once replay (`> cursor`):** safe against duplicate delivery, unsafe against the drain-race above. Correct only when the client can independently detect a missing response (e.g., via request-level timeout and idempotent retry). This is what the observed deployment had, silently. - **At-least-once replay (`>= cursor`) with client dedup:** safe against the drain-race, requires the client to deduplicate by event ID. This is the correct choice for tool-call results, which are frequently non-idempotent. The boundary is crossed the moment a tool call with side effects is in flight during a transport migration. Before that point, `> cursor` is fine and cheaper. After it, `> cursor` is a latent duplicate-side-effect bug that only shows up under proxy timeouts, which is exactly when nobody is watching. ## Operational Effect The practical consequence is that migration correctness cannot be validated by "the client reconnected and the stream is live." Liveness is preserved in both contracts; only delivery completeness differs, and it differs by exactly one event. Teams that tested the migration with short-lived streams and clean disconnects saw no failures, because a clean disconnect happens after client-side drain, when `> cursor` and `>= cursor` are equivalent. The bug lives only in the unclean-disconnect path. The mitigation that held in practice was threefold: namespace event IDs by transport so cross-transport cursors are rejected outright; require streamable-HTTP clients resuming a bridged legacy session to re-initialize rather than resume (accept the latency, avoid the race); and instrument the resume handler to log when a cursor's event ID equals the last-sent-but-unacknowledged ID, which is the tell for the drain-race. The third item is the cheapest and catches the condition even if the first two are imperfect. ```python class ResumeCursorRaceError(RuntimeError): """Raised when a streamable-HTTP resume cursor points at an event the client has not drained, causing silent loss of exactly one event.""" pass def resume_stream(session, last_event_id, transport_of_cursor, transport_of_session): if transport_of_cursor != transport_of_session: # Cross-transport cursor: the event-ID namespaces are disjoint by # construction, so this cursor cannot refer to a consumable event. raise ResumeCursorRaceError( f"cursor {last_event_id!r} minted by {transport_of_cursor!r} " f"cannot resume {transport_of_session!r} session {session.id!r}; " f"force re-initialization instead of replay" ) last_sent = session.last_sent_event_id last_acked = session.last_acked_event_id if last_event_id == last_sent and last_event_id != last_acked: # The client is resuming from an event the server sent but never saw # acknowledged. Replaying with "> cursor" would skip it. raise ResumeCursorRaceError( f"resume cursor {last_event_id!r} equals last-sent-but-unacked " f"event for session {session.id!r}; at-most-once replay would " f"drop this event. Use '>= cursor' with client dedup, or " f"re-initialize." ) cursor = session.event_index(last_event_id) if cursor is None: raise ResumeCursorRaceError( f"cursor {last_event_id!r} not found in session {session.id!r} " f"event log; refusing to replay from an unknown position" ) return session.replay_from(cursor, inclusive=True) ```