From 64de7b5e4b7e07cd44ab5b155aa74679367a1dc0 Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:13:06 +0530 Subject: [PATCH 1/2] feat(client): add maximum MCP protocol version option --- docs/concepts/capabilities/capabilities.md | 13 ++ docs/concepts/stateless/stateless.md | 2 +- .../Client/McpClient.Methods.cs | 21 +++ .../Client/McpClientImpl.cs | 48 +++++-- .../Client/McpClientOptions.cs | 31 ++++- .../Client/July2026ProtocolFallbackTests.cs | 126 +++++++++++++++++- 6 files changed, 221 insertions(+), 20 deletions(-) diff --git a/docs/concepts/capabilities/capabilities.md b/docs/concepts/capabilities/capabilities.md index ffa98f5a9..eb6f4719a 100644 --- a/docs/concepts/capabilities/capabilities.md +++ b/docs/concepts/capabilities/capabilities.md @@ -128,3 +128,16 @@ string? version = server.NegotiatedProtocolVersion; ``` Version negotiation is handled automatically. If the client and server cannot agree on a compatible protocol version, the initialization fails with an error. + +To keep an agent or gateway on a tested protocol range while still connecting to older servers, set +: + +```csharp +var options = new McpClientOptions { MaximumProtocolVersion = "2025-11-25" }; +await using McpClient client = await McpClient.CreateAsync(transport, options); +``` + +The client requests the ceiling and accepts a mutually supported older revision. This differs from +, which requires an exact version. +Set only one of these properties. Leaving both unset preserves automatic negotiation with the newest +supported revision. diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 5c72fc48b..d3f6bb9a1 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -62,7 +62,7 @@ On a dual-era stdio server, discovery alone does not commit the connection to th The era is cached per instance, so the probe cost is paid only on the first connect. -**Opting out of fallback.** Pin to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an instead of silently degrading to an initialize-capable revision. This is useful for strict `2026-07-28` production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value. +**Opting out of fallback.** Pin to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an instead of silently degrading to an initialize-capable revision. This is useful for strict `2026-07-28` production code and for tests that need to assert `2026-07-28`-only behavior. To accept older revisions while excluding newer ones, set instead. ```csharp var clientOptions = new McpClientOptions diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index bccdd7a2e..f5e6428df 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -44,6 +44,7 @@ public static async Task CreateAsync( CancellationToken cancellationToken = default) { Throw.IfNull(clientTransport); + ValidateProtocolVersionSelection(clientOptions); var transport = await clientTransport.ConnectAsync(cancellationToken).ConfigureAwait(false); var endpointName = clientTransport.Name; @@ -117,6 +118,16 @@ public static async Task ResumeSessionAsync( Throw.IfNull(resumeOptions); Throw.IfNull(resumeOptions.ServerCapabilities); Throw.IfNull(resumeOptions.ServerInfo); + ValidateProtocolVersionSelection(clientOptions); + + if (clientOptions?.MaximumProtocolVersion is { } maximumVersion && + resumeOptions.NegotiatedProtocolVersion is { } resumedVersion && + StringComparer.Ordinal.Compare(resumedVersion, maximumVersion) > 0) + { + throw new ArgumentException( + $"The resumed protocol version '{resumedVersion}' exceeds the configured maximum '{maximumVersion}'.", + nameof(resumeOptions)); + } var transport = await clientTransport.ConnectAsync(cancellationToken).ConfigureAwait(false); @@ -126,6 +137,16 @@ public static async Task ResumeSessionAsync( return clientSession; } + private static void ValidateProtocolVersionSelection(McpClientOptions? options) + { + if (options?.ProtocolVersion is not null && options.MaximumProtocolVersion is not null) + { + throw new ArgumentException( + $"{nameof(McpClientOptions.ProtocolVersion)} and {nameof(McpClientOptions.MaximumProtocolVersion)} cannot both be set.", + nameof(options)); + } + } + /// /// Sends a ping request to verify server connectivity. /// diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 51aa4b6a1..4bdfc37bd 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -293,17 +293,18 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) try { + string? maximumVersion = _options.MaximumProtocolVersion; + string preferredVersion = _options.ProtocolVersion ?? maximumVersion ?? McpProtocolVersions.July2026ProtocolVersion; // The 2026-07-28 revision (SEP-2575) is the default: there is no initialize // handshake. Instead, the client calls server/discover to learn the server's // capabilities and then begins sending normal RPCs that carry protocolVersion / // clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion // prefers the 2026-07-28 revision and automatically falls back to the initialize - // handshake when the server doesn't support it. HTTP+SSE defaults to the initialize handshake, - // including when AutoDetect selects it while sending the discovery probe. - if (_options.ProtocolVersion is null || McpProtocolVersions.RequiresPerRequestMetadata(_options.ProtocolVersion)) + // handshake when the server doesn't support it. A legacy maximum skips discovery. + // HTTP+SSE defaults to the initialize handshake, including when AutoDetect selects it + // while sending the discovery probe. + if (McpProtocolVersions.RequiresPerRequestMetadata(preferredVersion)) { - string preferredVersion = _options.ProtocolVersion ?? McpProtocolVersions.July2026ProtocolVersion; - DiscoverResult? discoverResult = null; // Modern-over-SSE is unusual, but honor an explicit version choice instead of forcing initialize. bool fallbackToInitialize = _transport is SseClientSessionTransport && _options.ProtocolVersion is null; @@ -341,6 +342,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) serverSupportedVersions = (IList)ex.Supported; var retryVersion = serverSupportedVersions .Where(McpProtocolVersions.PerRequestMetadataProtocolVersions.Contains) + .Where(v => maximumVersion is null || StringComparer.Ordinal.Compare(v, maximumVersion) <= 0) .OrderByDescending(v => v, StringComparer.Ordinal) .FirstOrDefault(); @@ -430,9 +432,20 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) string fallbackVersion = serverSupportedVersions? .Where(McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains) + .Where(v => maximumVersion is null || StringComparer.Ordinal.Compare(v, maximumVersion) <= 0) .OrderByDescending(v => v, StringComparer.Ordinal) .FirstOrDefault() - ?? McpProtocolVersions.November2025ProtocolVersion; + ?? (maximumVersion is not null && McpProtocolVersions.SupportsInitializeHandshake(maximumVersion) + ? maximumVersion + : McpProtocolVersions.November2025ProtocolVersion); + + if (maximumVersion is not null && serverSupportedVersions is not null && + !serverSupportedVersions.Contains(fallbackVersion)) + { + throw new McpException( + $"No mutually supported MCP protocol version at or below '{maximumVersion}'. " + + $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}."); + } // A non-null ProtocolVersion is also the minimum: refuse to fall back below the // explicitly requested version. String.Compare is the spec's prescribed ordering @@ -486,11 +499,9 @@ async Task SendDiscoverAsync(string protocolVersion, Cancellatio } else { - // initialize handshake. Reached only when the caller explicitly pinned a - // ProtocolVersion that still supports Streamable HTTP sessions (opting out of the default), so - // _options.ProtocolVersion is non-null here. - string requestProtocol = _options.ProtocolVersion ?? McpProtocolVersions.November2025ProtocolVersion; - await PerformInitializeHandshakeAsync(requestProtocol, initializationToken).ConfigureAwait(false); + // initialize handshake. Reached when the caller pinned an initialize-capable version + // or set a legacy maximum, skipping discovery in either case. + await PerformInitializeHandshakeAsync(preferredVersion, initializationToken).ConfigureAwait(false); } } catch (OperationCanceledException oce) when (initializationToken.IsCancellationRequested && !cancellationToken.IsCancellationRequested) @@ -573,11 +584,20 @@ private async Task PerformInitializeHandshakeAsync(string requestProtocol, Cance } else { - isResponseProtocolValid = McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains(initializeResponse.ProtocolVersion); + isResponseProtocolValid = McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains(initializeResponse.ProtocolVersion) && + (_options.MaximumProtocolVersion is null || + StringComparer.Ordinal.Compare(initializeResponse.ProtocolVersion, _options.MaximumProtocolVersion) <= 0); } if (!isResponseProtocolValid) { LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion); + if (_options.MaximumProtocolVersion is { } maximumVersion) + { + throw new McpException( + $"Server protocol version '{initializeResponse.ProtocolVersion}' is not a supported initialize-handshake version " + + $"at or below the configured maximum '{maximumVersion}'."); + } + throw new McpException($"Server protocol version mismatch. Expected {requestProtocol}, got {initializeResponse.ProtocolVersion}"); } @@ -608,7 +628,9 @@ internal void ResumeSession(ResumeClientSessionOptions resumeOptions) _serverInstructions = resumeOptions.ServerInstructions; _negotiatedProtocolVersion = resumeOptions.NegotiatedProtocolVersion ?? _options.ProtocolVersion - ?? McpProtocolVersions.November2025ProtocolVersion; + ?? (_options.MaximumProtocolVersion is { } maximumVersion && McpProtocolVersions.SupportsInitializeHandshake(maximumVersion) + ? maximumVersion + : McpProtocolVersions.November2025ProtocolVersion); // Update session handler with the negotiated protocol version for telemetry _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 7d9c701cc..1a476aec6 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -67,8 +67,8 @@ public sealed class McpClientOptions /// instead of falling back. Setting it to 2026-07-28 therefore disables /// the automatic initialize-handshake server fallback, and setting it to a version that still supports Streamable HTTP /// sessions, such as 2025-11-25, forces the initialize handshake and fails if the server - /// negotiates a different version. To try more than one version, leave this unset for automatic fallback - /// or retry the connection with a different value. + /// negotiates a different version. To accept older versions while setting an upper bound, use + /// instead. /// /// /// HTTP+SSE connections use the initialize handshake by default. @@ -77,6 +77,30 @@ public sealed class McpClientOptions /// public string? ProtocolVersion { get; set; } + /// + /// Gets or sets the newest protocol version the client is willing to negotiate. + /// + /// + /// Unlike , this is a ceiling rather than an exact version: the server may + /// select an older version supported by the SDK. It must be one of the SDK's supported protocol versions + /// and cannot be set together with . When omitted, the latest supported + /// version is preferred. + /// + /// The value is not a supported protocol version. + public string? MaximumProtocolVersion + { + get; + set + { + if (value is not null && !McpProtocolVersions.IsSupportedProtocolVersion(value)) + { + throw new ArgumentException($"Unsupported MCP protocol version '{value}'.", nameof(value)); + } + + field = value; + } + } + /// /// Gets or sets a timeout for the client-server initialization handshake sequence. /// @@ -132,7 +156,8 @@ public TimeProvider TimeProvider /// /// /// This timeout only has an effect when the client prefers the 2026-07-28 protocol revision, that is, - /// when is (the default) or 2026-07-28. + /// when is 2026-07-28, or when it is + /// and is unset or 2026-07-28. /// In that mode the client first probes the server with a /// server/discover request. A server that predates the 2026-07-28 revision may /// silently drop the unknown method, so the probe is bounded by this timeout; when it elapses the diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index ed7c98634..65716dfc6 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -25,6 +25,126 @@ namespace ModelContextProtocol.Tests.Client; /// public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) { + [Theory] + [InlineData(McpProtocolVersions.November2025ProtocolVersion, McpProtocolVersions.November2025ProtocolVersion)] + [InlineData(McpProtocolVersions.November2025ProtocolVersion, McpProtocolVersions.June2025ProtocolVersion)] + [InlineData(McpProtocolVersions.June2025ProtocolVersion, McpProtocolVersions.March2025ProtocolVersion)] + public async Task MaximumProtocolVersion_InitializeAcceptsOlderSupportedVersion(string maximum, string negotiated) + { + await using var transport = new InitializeHandshakeServerTestTransport(negotiated); + await using var client = await McpClient.CreateAsync(transport, + new McpClientOptions { MaximumProtocolVersion = maximum }, + LoggerFactory, TestContext.Current.CancellationToken); + + Assert.False(transport.ServerDiscoverProbed); + Assert.Equal(maximum, transport.InitializeProtocolVersion); + Assert.Equal(negotiated, client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task MaximumProtocolVersion_ModernVersionUsesDiscover() + { + await using var transport = new PerRequestMetadataRetryTestTransport(); + await using var client = await McpClient.CreateAsync(transport, + new McpClientOptions { MaximumProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }, + LoggerFactory, TestContext.Current.CancellationToken); + + Assert.Equal(2, transport.ServerDiscoverRequests); + Assert.False(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task MaximumProtocolVersion_ModernCeilingAllowsInitializeFallback() + { + await using var transport = new InitializeHandshakeServerTestTransport(McpProtocolVersions.June2025ProtocolVersion); + await using var client = await McpClient.CreateAsync(transport, + new McpClientOptions { MaximumProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }, + LoggerFactory, TestContext.Current.CancellationToken); + + Assert.True(transport.ServerDiscoverProbed); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion); + } + + [Theory] + [InlineData(McpProtocolVersions.November2025ProtocolVersion)] + [InlineData("not-a-protocol-version")] + public async Task MaximumProtocolVersion_RejectsNewerOrMalformedInitializeResponse(string serverVersion) + { + await using var transport = new InitializeHandshakeServerTestTransport(serverVersion); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, + new McpClientOptions { MaximumProtocolVersion = McpProtocolVersions.June2025ProtocolVersion }, + LoggerFactory, TestContext.Current.CancellationToken); + }); + + Assert.False(transport.ServerDiscoverProbed); + Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Contains(serverVersion, exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task MaximumProtocolVersion_RejectsDiscoverWithoutMutualVersion() + { + await using var transport = new PerRequestMetadataRetryTestTransport(["2027-01-01"]); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, + new McpClientOptions { MaximumProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }, + LoggerFactory, TestContext.Current.CancellationToken); + }); + + Assert.Equal(1, transport.ServerDiscoverRequests); + Assert.False(transport.InitializeReceived); + Assert.Contains("No mutually supported", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task MaximumProtocolVersion_RejectsConflictWithExactVersionBeforeConnecting() + { + await using var transport = new InitializeHandshakeServerTestTransport(McpProtocolVersions.November2025ProtocolVersion); + + await Assert.ThrowsAsync(() => McpClient.CreateAsync(transport, + new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + MaximumProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }, LoggerFactory, TestContext.Current.CancellationToken)); + + Assert.False(transport.ServerDiscoverProbed); + Assert.False(transport.InitializeReceived); + } + + [Fact] + public void MaximumProtocolVersion_RejectsUnsupportedVersion() + { + Assert.Throws(() => new McpClientOptions { MaximumProtocolVersion = "2026-01-01" }); + } + + [Fact] + public async Task MaximumProtocolVersion_RejectsResumeAboveCeiling() + { + await using var transport = new InitializeHandshakeServerTestTransport(McpProtocolVersions.November2025ProtocolVersion); + var resumeOptions = new ResumeClientSessionOptions + { + ServerCapabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "test-server", Version = "1.0" }, + NegotiatedProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }; + + await Assert.ThrowsAsync(() => McpClient.ResumeSessionAsync(transport, + resumeOptions, + new McpClientOptions { MaximumProtocolVersion = McpProtocolVersions.June2025ProtocolVersion }, + LoggerFactory, TestContext.Current.CancellationToken)); + + Assert.False(transport.ServerDiscoverProbed); + Assert.False(transport.InitializeReceived); + } + [Fact] public async Task Client_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion() { @@ -761,7 +881,7 @@ public ValueTask DisposeAsync() } } - private sealed class PerRequestMetadataRetryTestTransport : IClientTransport + private sealed class PerRequestMetadataRetryTestTransport(IList? supportedVersions = null) : IClientTransport { private readonly Channel _incomingToClient = Channel.CreateUnbounded(); @@ -827,12 +947,12 @@ private void HandleOutgoingMessage(JsonRpcMessage message) private Task WriteAsync(JsonRpcMessage message) => _incomingToClient.Writer.WriteAsync(message, CancellationToken.None).AsTask(); - private static JsonElement CreateUnsupportedProtocolVersionData() + private JsonElement CreateUnsupportedProtocolVersionData() { var json = JsonSerializer.Serialize(new UnsupportedProtocolVersionErrorData { Requested = McpProtocolVersions.July2026ProtocolVersion, - Supported = [McpProtocolVersions.July2026ProtocolVersion], + Supported = supportedVersions ?? [McpProtocolVersions.July2026ProtocolVersion], }, McpJsonUtilities.DefaultOptions); using var document = JsonDocument.Parse(json); From a5b8f0221b2f6fffc807c1d54978e4109b55f62d Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:37:25 +0530 Subject: [PATCH 2/2] test(client): note future ceiling coverage --- .../Client/July2026ProtocolFallbackTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index 65716dfc6..d15a894c6 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -44,6 +44,8 @@ public async Task MaximumProtocolVersion_InitializeAcceptsOlderSupportedVersion( [Fact] public async Task MaximumProtocolVersion_ModernVersionUsesDiscover() { + // When a revision after 2026-07-28 is supported, add a test proving that a lower ceiling + // excludes newer server-advertised versions during discovery and initialize fallback. await using var transport = new PerRequestMetadataRetryTestTransport(); await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { MaximumProtocolVersion = McpProtocolVersions.July2026ProtocolVersion },