diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs index 483e3643e..e28cc5a09 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs @@ -63,6 +63,20 @@ public sealed class ClientOAuthOptions /// public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; } + /// + /// Gets or sets the delegate for handling protected resource metadata response. + /// + /// + /// + /// This delegate provides an opportunity to inspect or modify the protected resource metadata received from the protected resource. + /// If not specified, the protected resource metadata will be used as-is without any modifications. + /// + /// + /// If the metadata of the protected resource could not be found, the delegate will be invoked with a argument, allowing the consumers to supply defaults. + /// + /// + public Func? ProtectedResourceMetadataResponseDelegate { get; set; } + /// /// Gets or sets the authorization server selector function. /// diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 5b6aa8618..4dd508eb2 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -31,7 +31,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private readonly IDictionary _additionalAuthorizationParameters; private readonly Func, Uri?> _authServerSelector; private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate; + private readonly Func _protectedResourceMetadataResponseDelegate; private readonly Uri? _clientMetadataDocumentUri; + private readonly ITokenCache _tokenCache; // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591) private readonly string? _dcrClientName; @@ -45,7 +47,6 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private string? _clientId; private string? _clientSecret; private string? _tokenEndpointAuthMethod; - private ITokenCache _tokenCache; private AuthorizationServerMetadata? _authServerMetadata; /// @@ -85,6 +86,9 @@ public ClientOAuthProvider( // Set up authorization URL handler (use default if not provided) _authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler; + // Set up protected resource metadata response delegate + _protectedResourceMetadataResponseDelegate = options.ProtectedResourceMetadataResponseDelegate ?? DefaultProtectedResourceMetadataResponseHandler; + _dcrClientName = options.DynamicClientRegistration?.ClientName; _dcrClientUri = options.DynamicClientRegistration?.ClientUri; _dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken; @@ -116,6 +120,13 @@ public ClientOAuthProvider( return Task.FromResult(authorizationCode); } + /// + /// Default protected resource metadata response handler that simply returns the provided metadata without modification. + /// + /// The protected resource metadata. + /// The modified protected resource metadata. + private ProtectedResourceMetadata? DefaultProtectedResourceMetadataResponseHandler(ProtectedResourceMetadata? protectedResourceMetadata) => protectedResourceMetadata; + internal override async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken) { bool attemptedRefresh = false; @@ -803,8 +814,7 @@ private async Task ExtractProtectedResourceMetadata(H if (resourceMetadataUrl is not null) { - metadata = await FetchProtectedResourceMetadataAsync(new(resourceMetadataUrl), requireSuccess: true, cancellationToken).ConfigureAwait(false) - ?? throw new McpException($"Failed to fetch resource metadata from {resourceMetadataUrl}"); + metadata = await FetchProtectedResourceMetadataAsync(new(resourceMetadataUrl), requireSuccess: true, cancellationToken).ConfigureAwait(false); } else { @@ -818,8 +828,18 @@ private async Task ExtractProtectedResourceMetadata(H break; } } + } - if (metadata is null) + // Allow delegate to inspect or modify the metadata, and perform a defensive copy at the end. + metadata = _protectedResourceMetadataResponseDelegate(metadata)?.Clone(); + + if (metadata is null) + { + if (resourceMetadataUrl is not null) + { + throw new McpException($"Failed to fetch resource metadata from {resourceMetadataUrl}"); + } + else { throw new McpException($"Failed to find protected resource metadata at a well-known location for {_serverUrl}"); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index d40078304..4ebaff36d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -853,4 +853,79 @@ public async Task ResourceMetadata_PreservesExplicitTrailingSlash() await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + + [Fact] + public async Task ResourceMetadata_ProtectedResourceMetadataDelegate_Raised() + { + await using var app = await StartMcpServerAsync(); + + ProtectedResourceMetadata? capturedMetadata = null; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + ProtectedResourceMetadataResponseDelegate = metadata => + { + Assert.NotNull(metadata); + + return capturedMetadata = metadata; + } + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(capturedMetadata); + Assert.Contains(capturedMetadata.Resource, TestOAuthServer.ValidResources); + } + + [Fact] + public async Task ResourceMetadata_ProtectedResourceMetadataDelegate_CanModify() + { + // Create a scenario where the protected resource is broadcasting an incorrect resource indicator. + const string resourceIncorrect = "http://localhost:5001/not-the-right-resource"; + + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => + { + options.ResourceMetadata = new ProtectedResourceMetadata + { + Resource = resourceIncorrect, + AuthorizationServers = [OAuthServerUrl], + ScopesSupported = ["mcp:tools"], + }; + }); + + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + ProtectedResourceMetadataResponseDelegate = metadata => + { + Assert.NotNull(metadata); + + // Modify PRM and provide the right resource indicator. + metadata.Resource = McpServerUrl; + + return metadata; + } + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } }