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
14 changes: 14 additions & 0 deletions src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ public sealed class ClientOAuthOptions
/// </remarks>
public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; }

/// <summary>
/// Gets or sets the delegate for handling protected resource metadata response.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// If the metadata of the protected resource could not be found, the delegate will be invoked with a <see langword="null"/> argument, allowing the consumers to supply defaults.
/// </para>
/// </remarks>
public Func<ProtectedResourceMetadata?, ProtectedResourceMetadata?>? ProtectedResourceMetadataResponseDelegate { get; set; }

/// <summary>
/// Gets or sets the authorization server selector function.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
private readonly Func<ProtectedResourceMetadata?, ProtectedResourceMetadata?> _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;
Expand All @@ -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;

/// <summary>
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -116,6 +120,13 @@ public ClientOAuthProvider(
return Task.FromResult<string?>(authorizationCode);
}

/// <summary>
/// Default protected resource metadata response handler that simply returns the provided metadata without modification.
/// </summary>
/// <param name="protectedResourceMetadata">The protected resource metadata.</param>
/// <returns>The modified protected resource metadata.</returns>
private ProtectedResourceMetadata? DefaultProtectedResourceMetadataResponseHandler(ProtectedResourceMetadata? protectedResourceMetadata) => protectedResourceMetadata;

internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
{
bool attemptedRefresh = false;
Expand Down Expand Up @@ -803,8 +814,7 @@ private async Task<ProtectedResourceMetadata> 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
{
Expand All @@ -818,8 +828,18 @@ private async Task<ProtectedResourceMetadata> 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();
Copy link
Author

@FICTURE7 FICTURE7 Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performed a defensive copy here to prevent shenanigans from consumers capturing and mutating the PRM object outside of the PRM response delegate. That PRM object is potentially long lived.

Plus the clone method was already around.


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}");
}
Expand Down
75 changes: 75 additions & 0 deletions tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<McpAuthenticationOptions>(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);
}
}