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);
+ }
}