# Why does an instance of an MCP HTTP streaming server silently degrade to non-streaming behavior after a capability renegotiation **Date of Record:** 2026 November 14 **Scope:** Applies to MCP servers exposing tools over HTTP with server-sent-event (SSE) streaming, where the client may re-send an `initialize` request or send a `notifications/initialized` after a transport reconnect. Does not apply to stdio transports, nor to servers that reject all post-initialize `initialize` frames unconditionally. ## Core Distinction The failure mode is not "the stream broke." The failure mode is that the server accepted a *second* `initialize` handshake whose declared `capabilities` were a subset of the first, and then—correctly per the spec, incorrectly per operator intent—narrowed its own advertised tool-streaming surface for the remainder of the connection. The distinction that matters operationally: a client reconnect that re-issues `initialize` is not a no-op. It is a capability *replacement*, not a capability *merge*. Servers that treat the handshake as idempotent (returning the same `ServerCapabilities` regardless of what the client sent) mask the problem; servers that echo or intersect client-declared capabilities expose it. The non-obvious invariant: **the streaming behavior of a tool invocation is negotiated at handshake time, not at invocation time.** Once the second handshake completes with `capabilities.tools.streaming` absent or `false`, every subsequent `tools/call` on that session returns a single JSON-RPC response body rather than an SSE event stream—even though the tool itself is unchanged and the client is fully capable of consuming a stream. There is no per-call override, and no error is raised. ## What Was Observed A long-lived client (an agent runtime) reconnected its HTTP transport after a 502 from a load balancer. The reconnect logic replayed the full handshake: `initialize` → `notifications/initialized`. The replayed `initialize` was constructed by a code path that had defaulted `capabilities.tools.streaming` to `false` because the flag was read from a config object that had been reset during the reconnect. The server: 1. Accepted the second `initialize` (session reuse by `Mcp-Session-Id` header). 2. Computed negotiated capabilities as the intersection of its own and the client's declared set. 3. Dropped `streaming` from the negotiated set. 4. Returned `200 OK` with a well-formed `InitializeResult` reflecting the narrowed set. From that point forward, `tools/call` responses arrived as `Content-Type: application/json` with a single `result` object. The client's SSE parser read the body as a zero-event stream and surfaced an empty tool result. No JSON-RPC error, no HTTP error, no log line at `WARN` or above. The agent received a syntactically valid, semantically empty answer and continued reasoning on nothing. The tell was a `Content-Type` drift on the `tools/call` response: `text/event-stream` before the reconnect, `application/json` after. This is the only reliable signal at the client. The `InitializeResult` itself is not compared against the prior one by most clients. ## Condition of Validity This record is valid when all of the following hold: - The transport is HTTP (not stdio) and the server supports SSE for tool results. - The server keys session state by a header (e.g., `Mcp-Session-Id`) and permits `initialize` on an existing session rather than rejecting it with a protocol error. - The server derives per-session streaming behavior from the *last* `initialize` rather than from the first, or from a configured floor. - The client's reconnect path reconstructs `initialize` from mutable state rather than from a cached, validated handshake record. It does *not* apply when the server rejects re-initialization with `-32600 Invalid Request`, when the server pins capabilities at first handshake and ignores subsequent ones, or when streaming is enabled by a per-request header (`Accept: text/event-stream`) that the client sets unconditionally. The last case is the common mitigation and is worth checking before concluding this record applies. ## Decision Boundary The operator-facing decision is whether to treat a post-reconnect `initialize` as advisory or authoritative. - **Treat as authoritative (spec-literal):** the server must honor the new capability set. This is correct and produces this failure mode. It is the right choice only if the client is guaranteed to re-send a complete, validated handshake—which reconnect code paths rarely are. - **Treat as advisory (pin to first handshake):** the server refuses to narrow capabilities within a session and returns the original `InitializeResult` for any re-`initialize`. This eliminates the failure but violates the letter of the spec for clients that legitimately want to downgrade mid-session (rare, and arguably a session should be torn down and re-established instead). - **Reject re-initialization outright:** return `-32600`. The client must open a new session. This is the cleanest boundary and the one I now default to, because it forces the reconnect path to be explicit rather than silently renegotiating. The boundary condition that determines which side you are on: *does your reconnect path re-send `initialize`?* If yes, you are exposed unless the server rejects it or pins capabilities. If no, you are not exposed by this mechanism (though you may be exposed to session-expiry errors instead). ## Operational Effect The downstream effect is the dangerous part: silent degradation produces *plausible* results, not errors. An empty tool result is indistinguishable, to a reasoning agent, from a tool that legitimately returned nothing. The agent proceeds, and the failure surfaces much later as a wrong answer with no traceable cause. Monitoring on HTTP status codes and JSON-RPC error rates will not catch it. The only reliable detectors are: 1. Assert the `Content-Type` of every `tools/call` response matches the negotiated streaming mode. 2. Compare the `InitializeResult` capabilities against the cached first-handshake record on every reconnect; treat any narrowing as an error. 3. Log the negotiated capability set at session establishment and at every re-`initialize`, and alert on divergence. The invariant to encode: **a session's negotiated capabilities are immutable after first handshake, or the session is dead.** Anything in between is a silent-degradation surface. ```python class CapabilityRenegotiationError(RuntimeError): """Raised when a session's negotiated capabilities narrow without a session reset.""" pass def validate_reinitialize( session_id: str, prior_capabilities: dict, incoming_capabilities: dict, *, allow_narrowing: bool = False, ) -> dict: """Validate a post-reconnect `initialize` against the pinned session capabilities. Returns the capability set the server should honor for the remainder of the session. Raises if the incoming set would silently narrow streaming behavior and narrowing is not explicitly permitted. """ if not session_id: raise CapabilityRenegotiationError( "re-initialize received without a session identifier; " "cannot determine whether this is a new or existing session" ) prior_streaming = bool(prior_capabilities.get("tools", {}).get("streaming", False)) next_streaming = bool(incoming_capabilities.get("tools", {}).get("streaming", False)) if prior_streaming and not next_streaming and not allow_narrowing: # This is the silent-degradation path: the client is about to stop # receiving SSE for tools/call while still receiving 200 OK. raise CapabilityRenegotiationError( f"session {session_id!r} would lose tools.streaming on re-initialize; " "reject the handshake and force a new session, or pin capabilities " "to the first handshake" ) if not prior_streaming and next_streaming and not allow_narrowing: raise CapabilityRenegotiationError( f"session {session_id!r} would gain tools.streaming mid-session; " "this is a session reset, not a renegotiation" ) return prior_capabilities if not allow_narrowing else incoming_capabilities def handle_initialize(session_id, prior, incoming): # Default posture: pin to first handshake, reject renegotiation. return validate_reinitialize(session_id, prior, incoming, allow_narrowing=False) ```