diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 872d0572b..018cd0b0e 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -583,6 +583,11 @@ private async Task GetAuthServerMetadataAsync(Uri a // another well-known endpoint. throw; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Caller cancellation or the initialization timeout must not be reported as missing metadata. + throw; + } catch (Exception ex) { LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthServerMetadataCancellationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthServerMetadataCancellationTests.cs new file mode 100644 index 000000000..7a099575f --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthServerMetadataCancellationTests.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.Time.Testing; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.AspNetCore.Tests.OAuth; + +// Authorization server metadata discovery treats a failed request to one well-known endpoint as a reason +// to try the next one. Cancellation is different: it must reach the caller as cancellation (or as the +// initialization timeout) instead of "Failed to find .well-known/... metadata". +public class AuthServerMetadataCancellationTests(ITestOutputHelper outputHelper) : OAuthTestBase(outputHelper) +{ + private static readonly TimeSpan ProbeBudget = TimeSpan.FromSeconds(5); + private readonly FakeTimeProvider _timeProvider = new(); + private readonly AsyncGate _metadata = new(); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AuthServerMetadataDiscovery_ObservesCallerAndInitializationCancellation(bool initializationTimeout) + { + await using var app = await StartMcpServerAsync(); + using var httpClient = CreateHttpClientWithGatedAuthServerMetadata(); + await using var transport = CreateTransport(httpClient); + using var caller = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var options = new McpClientOptions + { + TimeProvider = _timeProvider, + DiscoverProbeTimeout = ProbeBudget, + InitializationTimeout = ProbeBudget * 4, + }; + + var connecting = McpClient.CreateAsync(transport, options, LoggerFactory, caller.Token); + await _metadata.WaitUntilEnteredAsync(connecting); + var metadataRequestToken = _metadata.Token; + + // The probe timeout is armed, but it must not fire while OAuth loads the metadata. + _timeProvider.Advance(ProbeBudget * 2); + Assert.False(metadataRequestToken.IsCancellationRequested); + + if (initializationTimeout) + { + _timeProvider.Advance(options.InitializationTimeout); + var error = await Assert.ThrowsAsync(() => connecting.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken)); + Assert.Equal("Initialization timed out", error.Message); + } + else + { + caller.Cancel(); + await Assert.ThrowsAnyAsync(() => connecting.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken)); + } + + await _metadata.Canceled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + + private HttpClient CreateHttpClientWithGatedAuthServerMetadata() + { + // The shared SocketsHttpHandler is owned and disposed by the base class. + var httpClient = new HttpClient(new GatedAuthServerMetadataHandler(_metadata) { InnerHandler = SocketsHttpHandler }, disposeHandler: false); + ConfigureHttpClient(httpClient); + return httpClient; + } + + private HttpClientTransport CreateTransport(HttpClient httpClient) => + new(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = (_, _) => throw new InvalidOperationException("Authorization should not be reached."), + }, + }, httpClient, LoggerFactory); + + // Holds the authorization server metadata requests until they are canceled. + private sealed class GatedAuthServerMetadataHandler(AsyncGate gate) : DelegatingHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + if (path.StartsWith("/.well-known/oauth-authorization-server", StringComparison.Ordinal) || + path.StartsWith("/.well-known/openid-configuration", StringComparison.Ordinal)) + { + await gate.WaitAsync(cancellationToken); + } + + return await base.SendAsync(request, cancellationToken); + } + } +}