From fca61bb1ec28e6dad68c99dd91e372a312b18cf2 Mon Sep 17 00:00:00 2001 From: FICTURE7 Date: Thu, 12 Feb 2026 23:20:39 +0400 Subject: [PATCH] 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. --- .../Authentication/ClientOAuthOptions.cs | 14 ++++ .../Authentication/ClientOAuthProvider.cs | 28 ++++++- .../OAuth/AuthTests.cs | 75 +++++++++++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) 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); + } }