MCP C# SDK Protocol Negotiation: Pin 2026-07-28 When Fallback Is Unsafe
MCP C# SDK protocol negotiation can quietly change the wire contract beneath an otherwise successful connection. The stable MCP C# SDK 2.0.0 release prefers the 2026-07-28 protocol, but it also keeps older servers working through automatic fallback. That compatibility is useful. It can also hide the fact that a client expecting sessionless behavior actually negotiated an initialize-era session. I treat the negotiated version as part of the application contract. If a feature or deployment assumption requires 2026-07-28 , I pin it and test the failure path. Why MCP C# SDK protocol negotiation can hide a downgrade The 2026-07-28 specification removes the initialize handshake and protocol-level HTTP sessions. Clients can call server/discover , and each request carries its protocol version and client capabilities. SDK 2.0 handles the transition for us. A default client first tries the modern path. If it reaches a server that requires stateful HTTP, the server refuses the modern revision and the client can negotiate an older, initialize-capable version instead. The distinction between compatibility and failure matters. The SDK recognizes negotiation responses and does not treat every outage as permission to downgrade. Network failures must still surface, while modern protocol errors carry typed information that can guide selection or rejection. Application retry code should preserve that distinction rather than catch every connection exception and blindly start a legacy flow. That is a successful connection, but it is not the same contract: | Server and client | Result | |---|---| | Stateless server, default client | 2026-07-28 , no session ID | | Stateful server, default client | Down-level version, session ID created | | Stateless server, pinned client | 2026-07-28 , no session ID | | Stateful server, pinned client | Connection fails instead of downgrading | The second row is where an upgrade can become misleading. Health checks stay green, yet code that assumes stateless requests, modern-only extensions, or no session affinity is now running under different rules. For example, the SDK's v2 Tasks extension requires the modern revision; a down-level connection cannot quietly provide an equivalent task wire contract. Pin 2026-07-28 when it is a requirement Leaving ProtocolVersion unset means compatibility mode. Setting it makes that revision the minimum the client accepts. static Task ConnectAsync(Uri endpoint, bool requireModern) { var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = endpoint, TransportMode = HttpTransportMode.StreamableHttp, }); McpClientOptions? options = requireModern ? new() { ProtocolVersion = "2026-07-28" } : null; return McpClient.CreateAsync(transport, options); } After connecting, I inspect both pieces of evidence: Console.WriteLine(client.NegotiatedProtocolVersion); Console.WriteLine(client.SessionId ?? " "); The SDK's stateless and stateful guidance documents another subtlety: the negotiated era is cached per transport instance. A test comparing default and pinned behavior should create a fresh transport for each connection. Reusing one can turn a negotiation test into a cache test. I also avoid inferring the protocol solely from SessionId . A null session is expected on modern stateless HTTP, but NegotiatedProtocolVersion is the direct record of what the peers selected. Logging both values makes a compatibility fallback visible without parsing transport frames. Turn fallback into a regression test I verified the contract with two local Streamable HTTP servers. Both use the stable ModelContextProtocol.AspNetCore 2.0.0 package; one sets Stateless = true , while the other explicitly requires a session. builder.Services .AddMcpServer() .WithHttpTransport(options => options.Stateless = stateless); var client = await ConnectAsync(endpoint, requireModern: false); if (stateless && client.NegotiatedProtocolVersion != "2026-07-28") throw new InvalidOperationException("Modern negotiation failed."); if (!stateless && string.IsNullOrWhiteSpace(client.SessionId)) throw new InvalidOperationException("Expected legacy session fallback."); The verifier binds Kestrel to an ephemeral loopback port, creates a new transport for every scenario, and shuts each server down after its assertion. It proves four outcomes: modern default success, compatible fallback, pinned modern success, and pinned rejection. That matrix catches changes on either side of the negotiation boundary. The strict case matters just as much. Against the stateful server, a client pinned to 2026-07-28 must throw McpException . Catching a broad Exception would make a timeout or transport failure look like proof that pinning worked, so the verifier accepts only the documented SDK exception. The complete offline verifier runs four deterministic scenarios over loopback HTTP. It needs no API key, external MCP server, model call, or paid service. When not to pin the protocol I would not pin merely because 2026-07-28 is newer. Automatic fallback is the right behavior for a general-purpose client that must connect to a mixed server fleet. Stateful mode is also legitimate when a server still needs unsolicited notifications, resource subscriptions, or compatibility with clients that do not support the modern flow. For a gradual rollout, I would use three checks: - Record NegotiatedProtocolVersion and whetherSessionId is present. - Alert when a server expected to be modern negotiates down. - Pin only after the fleet and required extensions pass the same negotiation matrix. This separates observation from enforcement. It also gives operators a useful error when a stateful server remains in the pool, instead of turning a compatibility change into an unexplained production failure. Pinning is not authentication, authorization, or capability validation. It only prevents a protocol downgrade. The application still needs to check advertised capabilities and apply its normal security controls. The sample also does not cover proxies, OAuth, cross-origin access, distributed state, or production host validation. Would you keep compatibility fallback enabled, or make 2026-07-28 a hard requirement for your MCP client? Happy building! Top comments (1) The pin is the part I would turn into a build failure. Fallback is fine during migration, but CI should prove which path the client actually negotiated. Otherwise compatibility mode slowly becomes a downgrade path that only shows up when prod has two transports to debug.
Comments
No comments yet. Start the discussion.