{"slug": "mcp-c-sdk-protocol-negotiation-pin-2026-07-28-when-fallback-is-unsafe", "title": "MCP C# SDK Protocol Negotiation: Pin 2026-07-28 When Fallback Is Unsafe", "summary": "The Model Context Protocol C# SDK 2.0.0 can silently downgrade the protocol version during negotiation, potentially changing the wire contract while still establishing a successful connection. Developers are advised to pin the protocol version to 2026-07-28 when fallback is unsafe, and to inspect the negotiated version and session ID to ensure the expected behavior.", "body_md": "MCP C# SDK protocol negotiation can quietly change the wire contract beneath an otherwise successful connection.\n\nThe stable [MCP C# SDK 2.0.0 release](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.0.0) prefers the `2026-07-28`\n\nprotocol, 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.\n\nI treat the negotiated version as part of the application contract. If a feature or deployment assumption requires `2026-07-28`\n\n, I pin it and test the failure path.\n\nThe [2026-07-28 specification](https://modelcontextprotocol.io/specification/2026-07-28/changelog) removes the `initialize`\n\nhandshake and protocol-level HTTP sessions. Clients can call `server/discover`\n\n, and each request carries its protocol version and client capabilities.\n\nSDK 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.\n\nThe 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.\n\nThat is a successful connection, but it is not the same contract:\n\n| Server and client | Result |\n|---|---|\n| Stateless server, default client |\n`2026-07-28` , no session ID |\n| Stateful server, default client | Down-level version, session ID created |\n| Stateless server, pinned client |\n`2026-07-28` , no session ID |\n| Stateful server, pinned client | Connection fails instead of downgrading |\n\nThe 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.\n\nLeaving `ProtocolVersion`\n\nunset means compatibility mode. Setting it makes that revision the minimum the client accepts.\n\n```\nstatic Task<McpClient> ConnectAsync(Uri endpoint, bool requireModern)\n{\n    var transport = new HttpClientTransport(new HttpClientTransportOptions\n    {\n        Endpoint = endpoint,\n        TransportMode = HttpTransportMode.StreamableHttp,\n    });\n\n    McpClientOptions? options = requireModern\n        ? new() { ProtocolVersion = \"2026-07-28\" }\n        : null;\n\n    return McpClient.CreateAsync(transport, options);\n}\n```\n\nAfter connecting, I inspect both pieces of evidence:\n\n```\nConsole.WriteLine(client.NegotiatedProtocolVersion);\nConsole.WriteLine(client.SessionId ?? \"<sessionless>\");\n```\n\nThe 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.\n\nI also avoid inferring the protocol solely from `SessionId`\n\n. A null session is expected on modern stateless HTTP, but `NegotiatedProtocolVersion`\n\nis the direct record of what the peers selected. Logging both values makes a compatibility fallback visible without parsing transport frames.\n\nI verified the contract with two local Streamable HTTP servers. Both use the stable `ModelContextProtocol.AspNetCore`\n\n2.0.0 package; one sets `Stateless = true`\n\n, while the other explicitly requires a session.\n\n``` js\nbuilder.Services\n    .AddMcpServer()\n    .WithHttpTransport(options => options.Stateless = stateless);\n\nvar client = await ConnectAsync(endpoint, requireModern: false);\n\nif (stateless && client.NegotiatedProtocolVersion != \"2026-07-28\")\n    throw new InvalidOperationException(\"Modern negotiation failed.\");\n\nif (!stateless && string.IsNullOrWhiteSpace(client.SessionId))\n    throw new InvalidOperationException(\"Expected legacy session fallback.\");\n```\n\nThe 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.\n\nThe strict case matters just as much. Against the stateful server, a client pinned to `2026-07-28`\n\nmust throw `McpException`\n\n. Catching a broad `Exception`\n\nwould make a timeout or transport failure look like proof that pinning worked, so the verifier accepts only the documented SDK exception.\n\nThe [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.\n\nI would not pin merely because `2026-07-28`\n\nis 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.\n\nFor a gradual rollout, I would use three checks:\n\n`NegotiatedProtocolVersion`\n\nand whether `SessionId`\n\nis 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.\n\nPinning 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.\n\nWould you keep compatibility fallback enabled, or make `2026-07-28`\n\na hard requirement for your MCP client?\n\nHappy building!", "url": "https://wpnews.pro/news/mcp-c-sdk-protocol-negotiation-pin-2026-07-28-when-fallback-is-unsafe", "canonical_source": "https://dev.to/ssukhpinder/mcp-c-sdk-protocol-negotiation-pin-2026-07-28-when-fallback-is-unsafe-2fhk", "published_at": "2026-08-13 20:37:16+00:00", "updated_at": "2026-08-13 20:49:38.658724+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["Model Context Protocol", "C# SDK", "2026-07-28"], "alternates": {"html": "https://wpnews.pro/news/mcp-c-sdk-protocol-negotiation-pin-2026-07-28-when-fallback-is-unsafe", "markdown": "https://wpnews.pro/news/mcp-c-sdk-protocol-negotiation-pin-2026-07-28-when-fallback-is-unsafe.md", "text": "https://wpnews.pro/news/mcp-c-sdk-protocol-negotiation-pin-2026-07-28-when-fallback-is-unsafe.txt", "jsonld": "https://wpnews.pro/news/mcp-c-sdk-protocol-negotiation-pin-2026-07-28-when-fallback-is-unsafe.jsonld"}}