Skip to content

Commit fca61bb

Browse files
committed
Add protected resource metadata response inspect & modify support
This patch adds `ProtectedResourceMetadataResponseDelegate` on `ClientOAuthOptions` which enables consumers to inspect and modify the response of the PRM request. This new delegate is also called when a PRM is not found, allowing consumers to supply one. This is useful for MCP servers which are not compliant with the latest MCP spec. For example Atlassian's MCP server that currently does not provide a PRM.
1 parent dbf91bc commit fca61bb

3 files changed

Lines changed: 113 additions & 4 deletions

File tree

src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ public sealed class ClientOAuthOptions
6363
/// </remarks>
6464
public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; }
6565

66+
/// <summary>
67+
/// Gets or sets the delegate for handling protected resource metadata response.
68+
/// </summary>
69+
/// <remarks>
70+
/// <para>
71+
/// This delegate provides an opportunity to inspect or modify the protected resource metadata received from the protected resource.
72+
/// If not specified, the protected resource metadata will be used as-is without any modifications.
73+
/// </para>
74+
/// <para>
75+
/// 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.
76+
/// </para>
77+
/// </remarks>
78+
public Func<ProtectedResourceMetadata?, ProtectedResourceMetadata?>? ProtectedResourceMetadataResponseDelegate { get; set; }
79+
6680
/// <summary>
6781
/// Gets or sets the authorization server selector function.
6882
/// </summary>

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
3131
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
3232
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
3333
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
34+
private readonly Func<ProtectedResourceMetadata?, ProtectedResourceMetadata?> _protectedResourceMetadataResponseDelegate;
3435
private readonly Uri? _clientMetadataDocumentUri;
36+
private readonly ITokenCache _tokenCache;
3537

3638
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
3739
private readonly string? _dcrClientName;
@@ -45,7 +47,6 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
4547
private string? _clientId;
4648
private string? _clientSecret;
4749
private string? _tokenEndpointAuthMethod;
48-
private ITokenCache _tokenCache;
4950
private AuthorizationServerMetadata? _authServerMetadata;
5051

5152
/// <summary>
@@ -85,6 +86,9 @@ public ClientOAuthProvider(
8586
// Set up authorization URL handler (use default if not provided)
8687
_authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler;
8788

89+
// Set up protected resource metadata response delegate
90+
_protectedResourceMetadataResponseDelegate = options.ProtectedResourceMetadataResponseDelegate ?? DefaultProtectedResourceMetadataResponseHandler;
91+
8892
_dcrClientName = options.DynamicClientRegistration?.ClientName;
8993
_dcrClientUri = options.DynamicClientRegistration?.ClientUri;
9094
_dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken;
@@ -116,6 +120,13 @@ public ClientOAuthProvider(
116120
return Task.FromResult<string?>(authorizationCode);
117121
}
118122

123+
/// <summary>
124+
/// Default protected resource metadata response handler that simply returns the provided metadata without modification.
125+
/// </summary>
126+
/// <param name="protectedResourceMetadata">The protected resource metadata.</param>
127+
/// <returns>The modified protected resource metadata.</returns>
128+
private ProtectedResourceMetadata? DefaultProtectedResourceMetadataResponseHandler(ProtectedResourceMetadata? protectedResourceMetadata) => protectedResourceMetadata;
129+
119130
internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
120131
{
121132
bool attemptedRefresh = false;
@@ -803,8 +814,7 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H
803814

804815
if (resourceMetadataUrl is not null)
805816
{
806-
metadata = await FetchProtectedResourceMetadataAsync(new(resourceMetadataUrl), requireSuccess: true, cancellationToken).ConfigureAwait(false)
807-
?? throw new McpException($"Failed to fetch resource metadata from {resourceMetadataUrl}");
817+
metadata = await FetchProtectedResourceMetadataAsync(new(resourceMetadataUrl), requireSuccess: true, cancellationToken).ConfigureAwait(false);
808818
}
809819
else
810820
{
@@ -818,8 +828,18 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H
818828
break;
819829
}
820830
}
831+
}
821832

822-
if (metadata is null)
833+
// Allow delegate to inspect or modify the metadata, and perform a defensive copy at the end.
834+
metadata = _protectedResourceMetadataResponseDelegate(metadata)?.Clone();
835+
836+
if (metadata is null)
837+
{
838+
if (resourceMetadataUrl is not null)
839+
{
840+
throw new McpException($"Failed to fetch resource metadata from {resourceMetadataUrl}");
841+
}
842+
else
823843
{
824844
throw new McpException($"Failed to find protected resource metadata at a well-known location for {_serverUrl}");
825845
}

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,4 +853,79 @@ public async Task ResourceMetadata_PreservesExplicitTrailingSlash()
853853
await using var client = await McpClient.CreateAsync(
854854
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
855855
}
856+
857+
[Fact]
858+
public async Task ResourceMetadata_ProtectedResourceMetadataDelegate_Raised()
859+
{
860+
await using var app = await StartMcpServerAsync();
861+
862+
ProtectedResourceMetadata? capturedMetadata = null;
863+
864+
await using var transport = new HttpClientTransport(new()
865+
{
866+
Endpoint = new(McpServerUrl),
867+
OAuth = new()
868+
{
869+
ClientId = "demo-client",
870+
ClientSecret = "demo-secret",
871+
RedirectUri = new Uri("http://localhost:1179/callback"),
872+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
873+
ProtectedResourceMetadataResponseDelegate = metadata =>
874+
{
875+
Assert.NotNull(metadata);
876+
877+
return capturedMetadata = metadata;
878+
}
879+
},
880+
}, HttpClient, LoggerFactory);
881+
882+
await using var client = await McpClient.CreateAsync(
883+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
884+
885+
Assert.NotNull(capturedMetadata);
886+
Assert.Contains(capturedMetadata.Resource, TestOAuthServer.ValidResources);
887+
}
888+
889+
[Fact]
890+
public async Task ResourceMetadata_ProtectedResourceMetadataDelegate_CanModify()
891+
{
892+
// Create a scenario where the protected resource is broadcasting an incorrect resource indicator.
893+
const string resourceIncorrect = "http://localhost:5001/not-the-right-resource";
894+
895+
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
896+
{
897+
options.ResourceMetadata = new ProtectedResourceMetadata
898+
{
899+
Resource = resourceIncorrect,
900+
AuthorizationServers = [OAuthServerUrl],
901+
ScopesSupported = ["mcp:tools"],
902+
};
903+
});
904+
905+
await using var app = await StartMcpServerAsync();
906+
907+
await using var transport = new HttpClientTransport(new()
908+
{
909+
Endpoint = new(McpServerUrl),
910+
OAuth = new()
911+
{
912+
ClientId = "demo-client",
913+
ClientSecret = "demo-secret",
914+
RedirectUri = new Uri("http://localhost:1179/callback"),
915+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
916+
ProtectedResourceMetadataResponseDelegate = metadata =>
917+
{
918+
Assert.NotNull(metadata);
919+
920+
// Modify PRM and provide the right resource indicator.
921+
metadata.Resource = McpServerUrl;
922+
923+
return metadata;
924+
}
925+
},
926+
}, HttpClient, LoggerFactory);
927+
928+
await using var client = await McpClient.CreateAsync(
929+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
930+
}
856931
}

0 commit comments

Comments
 (0)