# Why does an instance negotiate MCP capabilities successfully yet fail on its first tool call after transport downgrade **Date of Record:** 2026 November 8 **Scope:** Applies to MCP client/server pairs where the client advertises a preferred transport (e.g., Streamable HTTP) and the server responds with a negotiated fallback (e.g., SSE or stdio bridge). Does not apply to homogeneous deployments where both peers are pinned to the same transport version and capability set. ## Core Distinction Capability negotiation and transport negotiation are two separate handshakes that share a single session identifier but are not atomically bound to each other. The initialize exchange confirms *which capabilities* each side supports; the transport fallback confirms *which wire protocol* will carry them. When a server downgrades transport after advertising a capability set, the capability list remains valid at the semantic layer while the encoding expectations attached to it silently change. The failure is not a negotiation failure — negotiation succeeds. The failure is that the negotiated capability set was computed against the *preferred* transport's framing rules, and the post-downgrade transport cannot express every shape that set permits. ## What Was Observed A client initialized against a server advertising `tools`, `resources`, and `streaming-progress`. The server accepted Streamable HTTP in the `initialize` request, then returned a session that required SSE fallback because the reverse proxy in front of it stripped `Transfer-Encoding: chunked` on the POST path. The client logged `capabilities: {tools, resources, streaming-progress}` and `transport: sse (fallback)`. The first `tools/call` succeeded at the JSON-RPC layer but the result envelope omitted the `progressToken` field the client had been told to expect, because SSE framing in this server build emits progress as discrete `event: progress` frames rather than inline result fields. The client's tool-call handler blocked waiting for an inline progress token that would never arrive in the result body, and timed out at 30s. The server considered the call complete. No error was raised on either side; the call simply hung until the client's own deadline fired. The invariant that broke: *every capability advertised in `initialize` is expressible in every transport the server is willing to fall back to.* That invariant is not enforced by the spec, and most servers do not check it. ## Condition of Validity The distinction holds only when all three of the following are true: (1) the server's capability advertisement is static and not recomputed per transport; (2) at least one advertised capability has transport-specific encoding (progress notifications, resource subscriptions, multi-part streaming results); (3) the client binds its response parser to the capability set rather than to the negotiated transport. If the server recomputes capabilities after transport selection, or the client re-parses the capability set against the selected transport, the failure does not occur. It also does not occur when the fallback transport is a strict superset of the preferred transport's framing — rare in practice, and never true when falling back from Streamable HTTP to SSE. ## Decision Boundary Treat a transport downgrade as a *capability invalidation event*, not a transport-only event. Concretely: if the negotiated transport differs from the transport the client proposed in `initialize`, the client must either (a) re-request capabilities scoped to the selected transport, or (b) refuse any capability whose encoding is transport-dependent until it has observed one successful round-trip proving the encoding. The boundary is sharp: a single successful `tools/list` is *not* proof, because list responses are transport-agnostic. Only a call that exercises the transport-specific path (progress, streaming, subscription) is proof. Do not extend the session's trust to transport-dependent capabilities on the strength of list calls. ## Operational Effect The practical consequence is that "successful initialization" is a weaker signal than most client implementations treat it as. A client that gates tool invocation on initialization success alone will intermittently hang on the first transport-dependent call after any fallback, and the hang will be indistinguishable from a slow server until the client's own deadline fires. The mitigation is cheap and local: on any transport mismatch, mark transport-dependent capabilities as `unverified` and require one probe call per capability class before admitting user traffic. Record the fallback in the session metadata so that post-hoc debugging does not have to reconstruct which transport the capability set was computed against. ```python class TransportCapabilityMismatch(RuntimeError): """Raised when a capability was negotiated against a transport that cannot encode it.""" pass TRANSPORT_DEPENDENT = {"streaming-progress", "resource-subscriptions", "multipart-results"} def admit_capability(capability: str, negotiated_transport: str, preferred_transport: str, probed: set[str]) -> None: if negotiated_transport == preferred_transport: return if capability not in TRANSPORT_DEPENDENT: return if capability in probed: return raise TransportCapabilityMismatch( f"capability {capability!r} negotiated under {preferred_transport!r} " f"but session fell back to {negotiated_transport!r}; " f"no successful probe observed, refusing to admit transport-dependent call" ) def guard_tool_call(capabilities: list[str], negotiated_transport: str, preferred_transport: str, probed: set[str]) -> None: for cap in capabilities: admit_capability(cap, negotiated_transport, preferred_transport, probed) ```