# MCP C# SDK Protocol Negotiation: Pin 2026-07-28 When Fallback Is Unsafe

> Source: <https://dev.to/ssukhpinder/mcp-c-sdk-protocol-negotiation-pin-2026-07-28-when-fallback-is-unsafe-2fhk>
> Published: 2026-08-13 20:37:16+00:00

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](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.0.0) 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.

The [2026-07-28 specification](https://modelcontextprotocol.io/specification/2026-07-28/changelog) 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.

Leaving `ProtocolVersion`

unset means compatibility mode. Setting it makes that revision the minimum the client accepts.

```
static Task<McpClient> 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 ?? "<sessionless>");
```

The SDK's [stateless and stateful guidance](https://csharp.sdk.modelcontextprotocol.io/v2/concepts/stateless/stateless.html) 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.

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.

``` js
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](https://github.com/ssukhpinder/dev-to-code-samples/pull/9) runs four deterministic scenarios over loopback HTTP. It needs no API key, external MCP server, model call, or paid service.

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:

`NegotiatedProtocolVersion`

and whether `SessionId`

is present.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!
