# What structural signals indicate that MCP HTTP-native transport has silently degraded to SSE fallback with lost session resumption state **Date of Record:** 2026 November 14 **Scope:** Applies to Model Context Protocol servers exposing the HTTP-native transport (POST request/response) with streamable SSE as a fallback, where session resumption is keyed on `Mcp-Session-Id` and `Last-Event-ID`. Does not apply to pure stdio transports or to stateless tool endpoints that carry no session identifier. ## Core Distinction There is a difference between a server that *supports* SSE fallback and a server that is *currently operating* in SSE fallback because the HTTP-native path failed. The protocol permits both. The client, however, frequently cannot tell which mode it is in, because both modes return HTTP 200 and both eventually produce a valid JSON-RPC result. The boundary failure is not "SSE is broken" — it is that the transition from native to fallback is **silent, one-way within a session, and destroys resumption state** without surfacing a protocol-level error. The invariant that is violated: *a session that has received at least one successful HTTP-native response must either continue in native mode or be explicitly renegotiated.* In practice, servers downgrade mid-session and keep the original `Mcp-Session-Id`, so the client believes it holds a resumable native session while the server has already discarded the resumption cursor. ## What Was Observed Three structural signals appeared in sequence, none of which raised an error: 1. **Response framing shift.** The first N responses to `tools/call` arrived as `Content-Type: application/json` with a complete body. Subsequent responses arrived as `Content-Type: text/event-stream` carrying a single `event: message` frame with no following `data:` heartbeat. The status code remained 200 throughout. The client's transport layer accepted the content-type change as a legitimate fallback per spec. 2. **Session identifier stability without cursor stability.** The `Mcp-Session-Id` header remained byte-identical across the transition, but the server stopped emitting `id:` fields on SSE events. Without event IDs, `Last-Event-ID` resumption becomes undefined: the client has nothing to send on reconnect, and the server has no cursor to match. The session ID became a *label*, not a *resumption handle*. 3. **Reconnect amplification.** After a transient TCP reset, the client reconnected with the same `Mcp-Session-Id` and no `Last-Event-ID` (because none had been issued). The server treated this as a fresh SSE stream within the existing session and replayed the *entire* pending notification queue from the last acknowledged point — which, having no cursor, was the beginning of the session. Duplicate `notifications/tools/list_changed` events were delivered; a downstream idempotency assumption in the agent's tool cache broke. The trigger in this instance was a proxy that terminated HTTP-native keep-alive connections at 30 seconds but permitted SSE connections to persist indefinitely. The server's native handler saw the connection drop, failed the streaming write, and fell back to SSE for the remainder of the session rather than failing the request. ## Condition of Validity The silent-downgrade diagnosis is valid only when all of the following hold: - The client observed at least one prior HTTP-native (application/json) response under the same `Mcp-Session-Id`. - The content-type transition occurred without an intervening `Mcp-Session-Id` rotation and without a 4xx/5xx. - The SSE stream carried no `id:` fields, or carried IDs that do not monotonically continue the pre-transition sequence. If the session ID rotated, this is renegotiation, not silent downgrade — treat it as a new session and do not attempt resumption. If event IDs are present and monotonic across the transition, resumption is intact and the content-type change is cosmetic. ## Decision Boundary The operational rule: **treat a mid-session content-type transition to `text/event-stream` without monotonic event IDs as a resumption-state loss, and force session renegotiation rather than attempting `Last-Event-ID` recovery.** Do not retry the native request hoping to "get back" to native mode within the same session — the server has already committed its handler to fallback for that session's lifetime. Do not send `Last-Event-ID` derived from a pre-transition cursor; the server's fallback path does not honor it and will replay from an earlier point, producing duplicates. The correct action is to close the session, open a new one, and re-run capability negotiation, accepting the cost of re-listing tools and re-subscribing to notifications. The boundary is asymmetric: a session may enter fallback silently, but it may only exit fallback via explicit renegotiation. Clients that assume symmetry (that they can detect and reverse the downgrade) will accumulate duplicate notifications and stale tool schemas. ## Operational Effect When the boundary is respected, the observable cost is one extra negotiation round-trip per downgrade event, plus a re-fetch of tool listings. When it is violated, the failure is deferred and multiplicative: duplicate notifications inflate the agent's context, stale tool schemas cause calls against removed tools, and the replay-from-start behavior on each reconnect grows linearly with session age until the context window or the notification queue overflows. The failure surfaces far from its cause, typically as a tool-call error against a schema the server no longer exposes, which is why the structural signals — content-type shift, ID-less SSE, stable session ID — must be checked at the transport layer before any application-level debugging begins. ```python class SilentFallbackResumptionError(RuntimeError): """Raised when a session silently degraded to SSE fallback and lost resumption state.""" pass def classify_transport_transition( session_id_before: str, session_id_after: str, content_type_before: str, content_type_after: str, event_ids_after: list[int], ) -> str: if session_id_before != session_id_after: return "renegotiated_new_session" if content_type_before == "application/json" and content_type_after == "text/event-stream": if not event_ids_after or event_ids_after != sorted(event_ids_after): raise SilentFallbackResumptionError( f"session {session_id_after!r} downgraded to SSE fallback with " f"non-monotonic or absent event IDs; Last-Event-ID resumption is " f"undefined. Force renegotiation instead of resuming." ) return "fallback_with_intact_resumption" return "native_unchanged" def resume_or_renegotiate(transition: str, last_event_id: int | None) -> str: if transition == "fallback_with_intact_resumption": if last_event_id is None: raise SilentFallbackResumptionError( "fallback stream claims resumable IDs but client holds no cursor; " "refusing to resume without a defined Last-Event-ID." ) return f"resume_from:{last_event_id}" if transition == "renegotiated_new_session": return "negotiate_fresh" return "continue_native" ```