# How should an instance distinguish "stream ended cleanly" from "stream was abandoned" under MCP HTTP-native transport when the server is stateless and discovery is re-resolved per request **Date of Record:** 2026 November 13 **Scope:** Applies to MCP clients operating over the HTTP-native (streamable) transport where the server advertises itself as stateless and where server discovery metadata (endpoint URL, protocol version, capability set, session semantics) may be re-resolved between requests. Does not apply to stdio transport, nor to stateful HTTP servers that issue and require a durable session identifier across the stream lifecycle. ## Core Distinction The failure mode is a conflation between two events that look identical at the socket layer but have opposite operational meaning: a *clean stream termination* (the server finished emitting the response and closed the response body normally) versus an *abandoned stream* (the client or an intermediary dropped the connection, or the server lost its in-flight response because it held no state to resume it). Under a stateful transport, the session identifier disambiguates these: a clean end is followed by a well-formed completion signal within a session that remains addressable, while an abandonment leaves a session in a half-open state that the next request can probe. Under a stateless server with per-request discovery, there is no session to probe, no completion token that survives the connection, and no way to ask "did you finish?" — the next request is resolved against a freshly discovered endpoint that may be a different instance entirely. The non-obvious part: HTTP-native MCP streaming semantics permit the server to close the response body at any chunk boundary, and a stateless server is explicitly allowed to do so without emitting a terminal event, because it has no obligation to maintain resumability. Therefore the absence of a terminal event is *not* evidence of failure, and the presence of a TCP FIN is *not* evidence of success. The invariant that stateful deployments rely on — "stream close implies response complete" — is silently void here. ## What Was Observed In a deployment where the MCP server ran behind a stateless autoscaler with discovery re-resolved per request, a client observed the following sequence repeatedly: 1. Client issues a streaming request; discovery returns endpoint E1 with protocol version V and a capability set declaring streaming support. 2. Server begins emitting response chunks over E1. 3. At a chunk boundary — typically after a partial tool-call argument payload — the response body closes with a normal HTTP termination (no error status, no reset). 4. Client treats this as a complete response and attempts to parse the accumulated tool-call arguments. 5. Parsing fails because the argument JSON is truncated mid-token. 6. Client retries; discovery now returns endpoint E2 (a different instance, no shared state), which has no record of the prior request and begins a fresh response from the top. The observable signature is a *parse failure that is indistinguishable from a legitimate malformed payload*, followed by a retry that succeeds against a different backend and therefore masks the truncation. The client cannot tell whether it received a complete-but-invalid response (a server bug) or an incomplete-but-valid prefix (a transport abandonment). Both present as "JSON parse error on tool arguments." A second observation: when the client attempted to detect truncation by requiring a terminal event, it began rejecting *legitimate* clean terminations, because the stateless server was permitted to close without one. This produced a false-positive rate proportional to response length, since longer responses had more chunk boundaries at which a clean close could occur. ## Condition of Validity The distinction between clean termination and abandonment is only recoverable when at least one of the following holds: - The server emits a terminal event (a completion sentinel, an explicit end-of-stream marker, or a well-formed final message) *and* the client is permitted to require it. This requires the discovery metadata to declare a streaming contract that mandates the sentinel; a stateless server that does not declare it cannot be held to it. - The response framing is self-delimiting: the payload carries its own length or a content-integrity check that fails loudly on truncation, independent of transport close semantics. - Discovery resolution is sticky for the duration of a logical request, so that a retry lands on an instance that can be asked about the prior attempt. This is precisely what stateless discovery forbids. When none of these hold — stateless server, per-request discovery, no mandated sentinel, non-self-delimiting payload — the two events are *operationally indistinguishable* and the client must not pretend otherwise. Any logic that assumes "close means complete" is invalid in this regime, and any logic that assumes "no sentinel means incomplete" is equally invalid. ## Decision Boundary The boundary is drawn at the point where the client chooses a recovery action. Three regimes: - **Regime A (sentinel mandated and observed):** close after sentinel is clean; close before sentinel is abandonment. Retry is safe and idempotent only if the request itself is idempotent or carries a client-generated request key. - **Regime B (self-delimiting payload):** close is irrelevant; the payload's own integrity check decides. Truncation is detected by content, not by transport. Retry is safe under the same idempotency condition. - **Regime C (neither):** the client is in the indistinguishable regime. It must treat every close as *possibly* abandoned, must not parse partial tool-call arguments as if complete, and must surface the ambiguity rather than silently retry — because a silent retry against a freshly discovered stateless endpoint will mask the truncation and produce a response that appears valid but was assembled from a different backend's fresh start. The critical decision: in Regime C, the client must *fail closed on ambiguity* — raise a distinct, non-retryable-by-default error that names the ambiguity — rather than treating truncation as a transient network fault. Treating it as transient is the trap, because the retry succeeds and the bug disappears from telemetry. ## Operational Effect The practical consequence is that stateless MCP deployments with per-request discovery shift the burden of stream-completion detection entirely onto payload framing and client-side request keys. Operators who assume HTTP-native streaming inherits TCP's "close means done" semantics will see intermittent, length-correlated tool-call parse failures that vanish on retry and therefore never appear as errors in aggregate dashboards — only as elevated retry counts and occasional downstream tool invocations with truncated arguments. The correct mitigation is to require a terminal sentinel in discovery-declared capabilities, or to make every streamed payload self-delimiting, and to treat the indistinguishable regime as a hard configuration error rather than a runtime condition to be retried. ```python class StreamCompletionAmbiguousError(RuntimeError): """Raised when a stateless MCP stream closes without a terminal sentinel and the payload is not self-delimiting, making clean termination and abandonment operationally indistinguishable.""" pass def classify_stream_outcome( *, saw_terminal_sentinel: bool, payload_self_delimiting: bool, payload_integrity_ok: bool, discovery_sticky: bool, ) -> str: """Classify a closed MCP HTTP-native stream into a recovery regime. Returns one of: 'clean', 'abandoned', 'ambiguous'. Raises StreamCompletionAmbiguousError when the regime is indistinguishable and no safe recovery action exists. """ if saw_terminal_sentinel: # Regime A: sentinel is authoritative. return "clean" if payload_self_delimiting: # Regime B: content integrity decides, transport close is irrelevant. return "clean" if payload_integrity_ok else "abandoned" # Regime C: neither sentinel nor self-delimiting framing. if discovery_sticky: # A sticky resolver *might* let us probe the prior attempt, but the # stream contract still does not guarantee resumability. We refuse to # guess. raise StreamCompletionAmbiguousError( "stream closed without terminal sentinel and payload is not " "self-delimiting; sticky discovery does not restore resumability " "semantics" ) # Stateless, per-request discovery, no sentinel, no self-delimiting frame. # Every close is possibly-abandoned. Do not silently retry: a retry against # a freshly discovered endpoint will mask the truncation. raise StreamCompletionAmbiguousError( "stateless MCP stream closed at a chunk boundary with no terminal " "sentinel and no self-delimiting payload; clean termination and " "abandonment are indistinguishable — failing closed instead of " "retrying against re-resolved discovery" ) ```