Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/concepts/capabilities/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<xref:ModelContextProtocol.Client.McpClientOptions.MaximumProtocolVersion>:

```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
<xref:ModelContextProtocol.Client.McpClientOptions.ProtocolVersion>, which requires an exact version.
Set only one of these properties. Leaving both unset preserves automatic negotiation with the newest
supported revision.
2 changes: 1 addition & 1 deletion docs/concepts/stateless/stateless.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ On a dual-era stdio server, discovery alone does not commit the connection to th

The era is cached per <xref:ModelContextProtocol.Client.IClientTransport> instance, so the probe cost is paid only on the first connect.

**Opting out of fallback.** Pin <xref:ModelContextProtocol.Client.McpClientOptions.ProtocolVersion> 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 <xref:ModelContextProtocol.McpException> 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 <xref:ModelContextProtocol.Client.McpClientOptions.ProtocolVersion> 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 <xref:ModelContextProtocol.McpException> 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 <xref:ModelContextProtocol.Client.McpClientOptions.MaximumProtocolVersion> instead.

```csharp
var clientOptions = new McpClientOptions
Expand Down
21 changes: 21 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public static async Task<McpClient> CreateAsync(
CancellationToken cancellationToken = default)
{
Throw.IfNull(clientTransport);
ValidateProtocolVersionSelection(clientOptions);

var transport = await clientTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
var endpointName = clientTransport.Name;
Expand Down Expand Up @@ -117,6 +118,16 @@ public static async Task<McpClient> 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);

Expand All @@ -126,6 +137,16 @@ public static async Task<McpClient> 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));
}
}

/// <summary>
/// Sends a ping request to verify server connectivity.
/// </summary>
Expand Down
48 changes: 35 additions & 13 deletions src/ModelContextProtocol.Core/Client/McpClientImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -341,6 +342,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
serverSupportedVersions = (IList<string>)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();

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -486,11 +499,9 @@ async Task<DiscoverResult> 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)
Expand Down Expand Up @@ -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}");
}

Expand Down Expand Up @@ -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;
Expand Down
31 changes: 28 additions & 3 deletions src/ModelContextProtocol.Core/Client/McpClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ public sealed class McpClientOptions
/// <see cref="McpException"/> instead of falling back. Setting it to <c>2026-07-28</c> therefore disables
/// the automatic initialize-handshake server fallback, and setting it to a version that still supports Streamable HTTP
/// sessions, such as <c>2025-11-25</c>, forces the <c>initialize</c> 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
/// <see cref="MaximumProtocolVersion"/> instead.
/// </para>
/// <para>
/// HTTP+SSE connections use the <c>initialize</c> handshake by default.
Expand All @@ -77,6 +77,30 @@ public sealed class McpClientOptions
/// </remarks>
public string? ProtocolVersion { get; set; }

/// <summary>
/// Gets or sets the newest protocol version the client is willing to negotiate.
/// </summary>
/// <remarks>
/// Unlike <see cref="ProtocolVersion"/>, 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 <see cref="ProtocolVersion"/>. When omitted, the latest supported
/// version is preferred.
/// </remarks>
/// <exception cref="ArgumentException">The value is not a supported protocol version.</exception>
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;
}
}

/// <summary>
/// Gets or sets a timeout for the client-server initialization handshake sequence.
/// </summary>
Expand Down Expand Up @@ -132,7 +156,8 @@ public TimeProvider TimeProvider
/// <remarks>
/// <para>
/// This timeout only has an effect when the client prefers the <c>2026-07-28</c> protocol revision, that is,
/// when <see cref="ProtocolVersion"/> is <see langword="null"/> (the default) or <c>2026-07-28</c>.
/// when <see cref="ProtocolVersion"/> is <c>2026-07-28</c>, or when it is <see langword="null"/>
/// and <see cref="MaximumProtocolVersion"/> is unset or <c>2026-07-28</c>.
/// In that mode the client first probes the server with a
/// <c>server/discover</c> request. A server that predates the <c>2026-07-28</c> revision may
/// silently drop the unknown method, so the probe is bounded by this timeout; when it elapses the
Expand Down
Loading
Loading