From 5a85e15e0ab130d89e4291024c967ef9941e5fe8 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 8 Jul 2026 17:16:21 -0700 Subject: [PATCH] Add prior discovery client connections Adds an opt-in PriorDiscoverResult client option for trusted modern discovery snapshots, exposes McpClient.GetDiscoverResult(), and reuses the discovery adoption path so clients can skip server/discover when reconnecting with prior knowledge. Adds tests for zero-probe prior discovery, pinned-version validation, invalid legacy combinations, reusable snapshots, and 2026-07-28 per-request metadata emission. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/concepts/stateless/stateless.md | 11 + .../Client/McpClient.cs | 45 ++++ .../Client/McpClientImpl.cs | 151 +++++++++++++- .../Client/McpClientOptions.cs | 25 +++ .../Client/July2026ProtocolConnectionTests.cs | 194 ++++++++++++++++++ .../July2026ProtocolListMetaEmissionTests.cs | 19 ++ 6 files changed, 434 insertions(+), 11 deletions(-) diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 6750443df..1cc82b2b4 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -53,6 +53,17 @@ The `2026-07-28` protocol revision goes further than `Stateless = true`: it remo The era is cached per instance, so the probe cost is paid only on the first connect. +**Skipping the discovery round trip.** If you already trust a server's discovery information, provide it through when creating the client. The client skips `server/discover`, chooses a compatible modern protocol version from `SupportedVersions` (honoring when you pin one), and uses the supplied capabilities, server info, instructions, and cache hints as its connected state. You can get a reusable snapshot from a previous modern connection with . + +```csharp +DiscoverResult cachedDiscoverResult = await LoadCachedDiscoverResultAsync(); + +var clientOptions = new McpClientOptions +{ + PriorDiscoverResult = cachedDiscoverResult, +}; +``` + **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 a legacy revision. This is useful for strict-modern 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. ```csharp diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index da85e9792..2414d95ee 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -55,6 +55,51 @@ protected McpClient() /// public abstract string? ServerInstructions { get; } + /// + /// Gets a discovery result that can be reused as prior knowledge for a later connection to the same server. + /// + /// + /// A describing the connected server's supported protocol versions, + /// capabilities, implementation metadata, and instructions. + /// + /// + /// + /// The returned value can be supplied to to skip + /// the server/discover round trip when reconnecting to a trusted server. This method is intended + /// for clients connected with the 2026-07-28-or-later connection model. + /// + /// + /// The default implementation returns a minimal discovery result containing only the negotiated + /// protocol version. Concrete clients may override this to preserve the full server/discover + /// response, including every server-supported version and caching hints. + /// + /// + /// + /// The client is not connected or the negotiated protocol version does not use the 2026-07-28-or-later + /// connection model. + /// + public virtual DiscoverResult GetDiscoverResult() + { + if (NegotiatedProtocolVersion is not { } negotiatedProtocolVersion) + { + throw new InvalidOperationException("The client is not connected."); + } + + if (!McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(negotiatedProtocolVersion)) + { + throw new InvalidOperationException( + $"A discover result is only available for clients connected with protocol version {McpHttpHeaders.July2026ProtocolVersion} or later."); + } + + return new DiscoverResult + { + SupportedVersions = [negotiatedProtocolVersion], + Capabilities = ServerCapabilities, + ServerInfo = ServerInfo, + Instructions = ServerInstructions, + }; + } + /// /// Gets a that completes when the client session has completed. /// diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 39f6579e3..b03aa6c89 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -31,6 +31,7 @@ internal sealed partial class McpClientImpl : McpClient private Implementation? _serverInfo; private string? _serverInstructions; private string? _negotiatedProtocolVersion; + private DiscoverResult? _discoverResult; private bool _disposed; @@ -297,7 +298,12 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // prefers the 2026-07-28 revision and automatically falls back to the legacy initialize // handshake when the server doesn't support it. The legacy branch below runs only when // the caller explicitly pins a version that still supports Streamable HTTP sessions (opting out of the default). - if (_options.ProtocolVersion is null || McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(_options.ProtocolVersion)) + if (_options.PriorDiscoverResult is { } priorDiscoverResult) + { + string negotiatedVersion = GetPriorDiscoverProtocolVersion(priorDiscoverResult); + AdoptDiscoverResult(priorDiscoverResult, negotiatedVersion); + } + else if (_options.ProtocolVersion is null || McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(_options.ProtocolVersion)) { string preferredVersion = _options.ProtocolVersion ?? McpHttpHeaders.July2026ProtocolVersion; @@ -411,16 +417,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } else { - if (_logger.IsEnabled(LogLevel.Information)) - { - LogServerCapabilitiesReceived(_endpointName, - capabilities: JsonSerializer.Serialize(discoverResult!.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), - serverInfo: JsonSerializer.Serialize(discoverResult.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); - } - - _serverCapabilities = discoverResult!.Capabilities; - _serverInfo = discoverResult.ServerInfo; - _serverInstructions = discoverResult.Instructions; + AdoptDiscoverResult(discoverResult!, preferredVersion); } } else @@ -448,6 +445,138 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) LogClientConnected(_endpointName); } + /// + public override DiscoverResult GetDiscoverResult() + { + if (_discoverResult is not null) + { + return CloneDiscoverResult(_discoverResult); + } + + return base.GetDiscoverResult(); + } + + private string GetPriorDiscoverProtocolVersion(DiscoverResult discoverResult) + { + Throw.IfNull(discoverResult); + Throw.IfNull(discoverResult.SupportedVersions); + Throw.IfNull(discoverResult.Capabilities); + Throw.IfNull(discoverResult.ServerInfo); + + if (_options.ProtocolVersion is { } requestedProtocolVersion) + { + if (!McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(requestedProtocolVersion)) + { + throw new InvalidOperationException( + $"{nameof(McpClientOptions.PriorDiscoverResult)} can only be used with protocol version {McpHttpHeaders.July2026ProtocolVersion} or later."); + } + + if (!discoverResult.SupportedVersions.Contains(requestedProtocolVersion)) + { + throw new McpException( + $"The prior discovery result does not include the requested protocol version '{requestedProtocolVersion}'. " + + $"Server-supported versions: {string.Join(", ", discoverResult.SupportedVersions)}."); + } + + if (!McpSessionHandler.SupportedProtocolVersions.Contains(requestedProtocolVersion)) + { + throw new McpException($"The requested protocol version '{requestedProtocolVersion}' is not supported by this SDK."); + } + + return requestedProtocolVersion; + } + + string? negotiatedVersion = discoverResult.SupportedVersions + .Where(version => + McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(version) && + McpSessionHandler.SupportedProtocolVersions.Contains(version)) + .OrderByDescending(version => version, StringComparer.Ordinal) + .FirstOrDefault(); + + if (negotiatedVersion is null) + { + throw new McpException( + $"The prior discovery result does not contain a protocol version supported by this SDK for the " + + $"{McpHttpHeaders.July2026ProtocolVersion}-or-later connection model. " + + $"Server-supported versions: {string.Join(", ", discoverResult.SupportedVersions)}."); + } + + return negotiatedVersion; + } + + private void AdoptDiscoverResult(DiscoverResult discoverResult, string negotiatedVersion) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + LogServerCapabilitiesReceived(_endpointName, + capabilities: JsonSerializer.Serialize(discoverResult.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), + serverInfo: JsonSerializer.Serialize(discoverResult.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); + } + + _discoverResult = CloneDiscoverResult(discoverResult); + _serverCapabilities = _discoverResult.Capabilities; + _serverInfo = _discoverResult.ServerInfo; + _serverInstructions = _discoverResult.Instructions; + _negotiatedProtocolVersion = negotiatedVersion; + _sessionHandler.NegotiatedProtocolVersion = negotiatedVersion; + } + + private static DiscoverResult CloneDiscoverResult(DiscoverResult discoverResult) + { + return new DiscoverResult + { + SupportedVersions = [.. discoverResult.SupportedVersions], + Capabilities = CloneServerCapabilities(discoverResult.Capabilities), + ServerInfo = CloneImplementation(discoverResult.ServerInfo), + Instructions = discoverResult.Instructions, + TimeToLive = discoverResult.TimeToLive, + CacheScope = discoverResult.CacheScope, + Meta = discoverResult.Meta is null ? null : (JsonObject)discoverResult.Meta.DeepClone(), + ResultType = discoverResult.ResultType, + }; + } + +#pragma warning disable MCP9005 // LoggingCapability is deprecated but still part of the protocol DTO. + private static ServerCapabilities CloneServerCapabilities(ServerCapabilities capabilities) => + new() + { + Experimental = CloneObjectDictionary(capabilities.Experimental), + Logging = capabilities.Logging is null ? null : new LoggingCapability(), + Prompts = capabilities.Prompts is null ? null : new PromptsCapability { ListChanged = capabilities.Prompts.ListChanged }, + Resources = capabilities.Resources is null ? null : new ResourcesCapability + { + Subscribe = capabilities.Resources.Subscribe, + ListChanged = capabilities.Resources.ListChanged, + }, + Tools = capabilities.Tools is null ? null : new ToolsCapability { ListChanged = capabilities.Tools.ListChanged }, + Completions = capabilities.Completions is null ? null : new CompletionsCapability(), + Extensions = CloneObjectDictionary(capabilities.Extensions), + }; +#pragma warning restore MCP9005 + + private static Implementation CloneImplementation(Implementation implementation) => + new() + { + Name = implementation.Name, + Title = implementation.Title, + Version = implementation.Version, + Description = implementation.Description, + Icons = implementation.Icons is null ? null : [.. implementation.Icons.Select(CloneIcon)], + WebsiteUrl = implementation.WebsiteUrl, + }; + + private static Icon CloneIcon(Icon icon) => + new() + { + Source = icon.Source, + MimeType = icon.MimeType, + Sizes = icon.Sizes is null ? null : [.. icon.Sizes], + Theme = icon.Theme, + }; + + private static IDictionary? CloneObjectDictionary(IDictionary? values) => + values is null ? null : new Dictionary(values, StringComparer.Ordinal); + /// /// Performs the legacy initialize handshake (initialize request + initialized notification), /// records the negotiated protocol version, and stores the server capabilities/info/instructions. diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index aaf3cefa6..c0e0ab26c 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -69,6 +69,31 @@ public sealed class McpClientOptions /// public string? ProtocolVersion { get; set; } + /// + /// Gets or sets a trusted discovery result to use when connecting to a server. + /// + /// + /// + /// When set, the client skips the server/discover probe and initializes its modern + /// 2026-07-28-or-later connection state from this result. The result should come from a trusted + /// source, such as a previous call to for the same server + /// or an equivalent out-of-band configuration. + /// + /// + /// This option is only valid for protocol versions that use the discovery-based connection model. + /// If is set, it must be 2026-07-28 or later and must appear in + /// . When is + /// , the client chooses the newest protocol version in + /// that this SDK supports and that uses the modern + /// connection model. + /// + /// + /// Leave this unset to preserve the default safe behavior: probe with server/discover and + /// fall back to the legacy initialize handshake when appropriate. + /// + /// + public DiscoverResult? PriorDiscoverResult { get; set; } + /// /// Gets or sets a timeout for the client-server initialization handshake sequence. /// diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs index a06548a4f..87667e0d4 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Text.Json; +using System.Threading.Channels; namespace ModelContextProtocol.Tests.Client; @@ -87,4 +88,197 @@ public async Task ServerDiscover_IncludesJuly2026ProtocolVersion() Assert.NotNull(discoverResult); Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discoverResult.SupportedVersions); } + + [Fact] + public async Task Client_WithPriorDiscoverResult_SkipsServerDiscover() + { + StartServer(); + + var ct = TestContext.Current.CancellationToken; + await using var transport = new RecordingClientTransport(); + var priorDiscoverResult = CreatePriorDiscoverResult(); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + PriorDiscoverResult = priorDiscoverResult, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Empty(transport.SentMethods); + Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + Assert.Equal(priorDiscoverResult.ServerInfo.Name, client.ServerInfo.Name); + Assert.NotSame(priorDiscoverResult.Capabilities, client.ServerCapabilities); + Assert.NotNull(client.ServerCapabilities.Tools); + + var reusableDiscoverResult = client.GetDiscoverResult(); + Assert.NotSame(priorDiscoverResult, reusableDiscoverResult); + Assert.NotSame(priorDiscoverResult.SupportedVersions, reusableDiscoverResult.SupportedVersions); + Assert.NotSame(client.ServerCapabilities, reusableDiscoverResult.Capabilities); + Assert.NotSame(client.ServerInfo, reusableDiscoverResult.ServerInfo); + Assert.Equal(priorDiscoverResult.SupportedVersions, reusableDiscoverResult.SupportedVersions); + Assert.Equal(priorDiscoverResult.TimeToLive, reusableDiscoverResult.TimeToLive); + Assert.Equal(priorDiscoverResult.CacheScope, reusableDiscoverResult.CacheScope); + + reusableDiscoverResult.SupportedVersions.Clear(); + Assert.NotEmpty(client.GetDiscoverResult().SupportedVersions); + reusableDiscoverResult.Capabilities.Tools = null; + reusableDiscoverResult.ServerInfo.Name = "mutated"; + Assert.NotNull(client.GetDiscoverResult().Capabilities.Tools); + Assert.Equal(priorDiscoverResult.ServerInfo.Name, client.ServerInfo.Name); + } + + [Fact] + public async Task Client_WithPriorDiscoverResult_AndPinnedModernVersion_UsesPinnedVersion() + { + StartServer(); + + var ct = TestContext.Current.CancellationToken; + await using var transport = new RecordingClientTransport(); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + PriorDiscoverResult = CreatePriorDiscoverResult(), + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Empty(transport.SentMethods); + Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task Client_WithPriorDiscoverResult_AndLegacyProtocolVersion_Throws() + { + StartServer(); + + var ct = TestContext.Current.CancellationToken; + await using var transport = new RecordingClientTransport(); + + await Assert.ThrowsAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = LatestStableVersion, + PriorDiscoverResult = CreatePriorDiscoverResult(), + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.Empty(transport.SentMethods); + } + + [Fact] + public async Task Client_WithPriorDiscoverResult_AndNoCompatibleModernVersion_Throws() + { + StartServer(); + + var ct = TestContext.Current.CancellationToken; + await using var transport = new RecordingClientTransport(); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + PriorDiscoverResult = CreatePriorDiscoverResult([LatestStableVersion]), + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, exception.Message, StringComparison.Ordinal); + Assert.Empty(transport.SentMethods); + } + + [Fact] + public async Task Client_WithPriorDiscoverResult_AndPinnedModernVersionMissingFromPrior_Throws() + { + StartServer(); + + var ct = TestContext.Current.CancellationToken; + await using var transport = new RecordingClientTransport(); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + PriorDiscoverResult = CreatePriorDiscoverResult(["2026-08-01"]), + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, exception.Message, StringComparison.Ordinal); + Assert.Empty(transport.SentMethods); + } + + private static DiscoverResult CreatePriorDiscoverResult(IList? supportedVersions = null) + { + return new DiscoverResult + { + SupportedVersions = supportedVersions ?? [McpHttpHeaders.July2026ProtocolVersion, LatestStableVersion], + Capabilities = new ServerCapabilities + { + Tools = new ToolsCapability(), + }, + ServerInfo = new Implementation { Name = "prior-discovery-test-server", Version = "1.0" }, + Instructions = "Use prior knowledge.", + TimeToLive = TimeSpan.FromMinutes(10), + CacheScope = CacheScope.Private, + }; + } + + private sealed class RecordingClientTransport : IClientTransport, ITransport + { + private readonly Channel _incomingToClient = Channel.CreateUnbounded(); + private readonly List _sentMethods = []; + + public string Name => "recording-client-transport"; + + public ChannelReader MessageReader => _incomingToClient.Reader; + + public bool IsConnected { get; private set; } = true; + + public string? SessionId => null; + + public IReadOnlyList SentMethods => _sentMethods; + + public Task ConnectAsync(CancellationToken cancellationToken = default) => Task.FromResult(this); + + public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + { + if (message is JsonRpcRequest request) + { + _sentMethods.Add(request.Method); + + if (request.Method == RequestMethods.ServerDiscover) + { + _incomingToClient.Writer.TryWrite(new JsonRpcError + { + Id = request.Id, + Error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.MethodNotFound, + Message = "Method not found", + }, + }); + } + else if (request.Method == RequestMethods.Initialize) + { + _incomingToClient.Writer.TryWrite(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = LatestStableVersion, + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "legacy-test-server", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions), + }); + } + } + + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + _incomingToClient.Writer.TryComplete(); + IsConnected = false; + return default; + } + } } diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs index 19ff40287..89079676d 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs @@ -163,6 +163,25 @@ public async Task LegacyClient_ListTools_DoesNotEmitMeta() } } + [Fact] + public async Task Client_WithPriorDiscoverResult_ListTools_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + PriorDiscoverResult = new DiscoverResult + { + SupportedVersions = [McpHttpHeaders.July2026ProtocolVersion], + Capabilities = new ServerCapabilities { Tools = new ToolsCapability() }, + ServerInfo = new Implementation { Name = "prior-discovery-test-server", Version = "1.0" }, + }, + }); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertRequiredMetaPresent(RequestMethods.ToolsList); + } + private void AssertRequiredMetaPresent(string method) { Assert.True(_capturedMeta.TryGetValue(method, out var meta), $"No capture for {method}");