Fall back to well-known URL in client if 401 response is missing resource_metadata parameter (#1054)
This commit is contained in:
parent
d99f882884
commit
970207989e
6 changed files with 497 additions and 134 deletions
|
|
@ -11,8 +11,11 @@ namespace ModelContextProtocol.AspNetCore.Authentication;
|
|||
/// Represents an authentication handler for MCP protocol that adds resource metadata to challenge responses
|
||||
/// and handles resource metadata endpoint requests.
|
||||
/// </summary>
|
||||
public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationOptions>, IAuthenticationRequestHandler
|
||||
public partial class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationOptions>, IAuthenticationRequestHandler
|
||||
{
|
||||
private const string DefaultResourceMetadataPath = "/.well-known/oauth-protected-resource";
|
||||
private static readonly PathString DefaultResourceMetadataPrefix = new(DefaultResourceMetadataPath);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpAuthenticationHandler"/> class.
|
||||
/// </summary>
|
||||
|
|
@ -25,20 +28,19 @@ public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationO
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> HandleRequestAsync()
|
||||
public Task<bool> HandleRequestAsync()
|
||||
{
|
||||
// Check if the request is for the resource metadata endpoint
|
||||
string requestPath = Request.Path.Value ?? string.Empty;
|
||||
|
||||
string expectedMetadataPath = Options.ResourceMetadataUri?.ToString() ?? string.Empty;
|
||||
if (Options.ResourceMetadataUri != null && !Options.ResourceMetadataUri.IsAbsoluteUri)
|
||||
if (Options.ResourceMetadataUri is Uri configuredUri)
|
||||
{
|
||||
// For relative URIs, it's just the path component.
|
||||
expectedMetadataPath = Options.ResourceMetadataUri.OriginalString;
|
||||
return HandleConfiguredResourceMetadataRequestAsync(configuredUri);
|
||||
}
|
||||
|
||||
// If the path doesn't match, let the request continue through the pipeline
|
||||
if (!string.Equals(requestPath, expectedMetadataPath, StringComparison.OrdinalIgnoreCase))
|
||||
return HandleDefaultResourceMetadataRequestAsync();
|
||||
}
|
||||
|
||||
private async Task<bool> HandleConfiguredResourceMetadataRequestAsync(Uri resourceMetadataUri)
|
||||
{
|
||||
if (!IsConfiguredEndpointRequest(resourceMetadataUri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -46,46 +48,94 @@ public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationO
|
|||
return await HandleResourceMetadataRequestAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base URL from the current request, including scheme, host, and path base.
|
||||
/// </summary>
|
||||
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
|
||||
private async Task<bool> HandleDefaultResourceMetadataRequestAsync()
|
||||
{
|
||||
if (!Request.Path.StartsWithSegments(DefaultResourceMetadataPrefix, out var resourceSuffix))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var deriveResourceUriBuilder = new UriBuilder(Request.Scheme, Request.Host.Host)
|
||||
{
|
||||
Path = $"{Request.PathBase}{resourceSuffix}",
|
||||
};
|
||||
|
||||
if (Request.Host.Port is not null)
|
||||
{
|
||||
deriveResourceUriBuilder.Port = Request.Host.Port.Value;
|
||||
}
|
||||
|
||||
return await HandleResourceMetadataRequestAsync(deriveResourceUriBuilder.Uri);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute URI for the resource metadata endpoint.
|
||||
/// </summary>
|
||||
private string GetAbsoluteResourceMetadataUri()
|
||||
{
|
||||
var resourceMetadataUri = Options.ResourceMetadataUri;
|
||||
|
||||
string currentPath = resourceMetadataUri?.ToString() ?? string.Empty;
|
||||
|
||||
if (resourceMetadataUri != null && resourceMetadataUri.IsAbsoluteUri)
|
||||
if (Options.ResourceMetadataUri is Uri resourceMetadataUri)
|
||||
{
|
||||
return currentPath;
|
||||
if (resourceMetadataUri.IsAbsoluteUri)
|
||||
{
|
||||
return resourceMetadataUri.ToString();
|
||||
}
|
||||
|
||||
var separator = resourceMetadataUri.OriginalString.StartsWith('/') ? "" : "/";
|
||||
return $"{Request.Scheme}://{Request.Host.ToUriComponent()}{Request.PathBase}{separator}{resourceMetadataUri.OriginalString}";
|
||||
}
|
||||
|
||||
// For relative URIs, combine with the base URL
|
||||
string baseUrl = GetBaseUrl();
|
||||
string relativePath = resourceMetadataUri?.OriginalString.TrimStart('/') ?? string.Empty;
|
||||
|
||||
if (!Uri.TryCreate($"{baseUrl.TrimEnd('/')}/{relativePath}", UriKind.Absolute, out var absoluteUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Could not create absolute URI for resource metadata. Base URL: {baseUrl}, Relative Path: {relativePath}");
|
||||
}
|
||||
|
||||
return absoluteUri.ToString();
|
||||
return $"{Request.Scheme}://{Request.Host.ToUriComponent()}{Request.PathBase}{DefaultResourceMetadataPath}{Request.Path}";
|
||||
}
|
||||
|
||||
private async Task<bool> HandleResourceMetadataRequestAsync()
|
||||
private bool IsConfiguredEndpointRequest(Uri resourceMetadataUri)
|
||||
{
|
||||
var resourceMetadata = Options.ResourceMetadata;
|
||||
var expectedPath = GetConfiguredResourceMetadataPath(resourceMetadataUri);
|
||||
|
||||
if (!string.Equals(Request.Path.Value, expectedPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!resourceMetadataUri.IsAbsoluteUri)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Request.Host.Host, resourceMetadataUri.Host, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LogResourceMetadataHostMismatch(Logger, resourceMetadataUri.Host);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(Request.Scheme, resourceMetadataUri.Scheme, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LogResourceMetadataSchemeMismatch(Logger, resourceMetadataUri.Scheme);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string GetConfiguredResourceMetadataPath(Uri resourceMetadataUri)
|
||||
{
|
||||
if (resourceMetadataUri.IsAbsoluteUri)
|
||||
{
|
||||
return resourceMetadataUri.AbsolutePath;
|
||||
}
|
||||
|
||||
var path = resourceMetadataUri.OriginalString;
|
||||
return path.StartsWith('/') ? path : $"/{path}";
|
||||
}
|
||||
|
||||
private async Task<bool> HandleResourceMetadataRequestAsync(Uri? derivedResourceUri = null)
|
||||
{
|
||||
var resourceMetadata = CloneResourceMetadata(Options.ResourceMetadata, derivedResourceUri);
|
||||
|
||||
if (Options.Events.OnResourceMetadataRequest is not null)
|
||||
{
|
||||
var context = new ResourceMetadataRequestContext(Request.HttpContext, Scheme, Options)
|
||||
{
|
||||
ResourceMetadata = CloneResourceMetadata(resourceMetadata),
|
||||
ResourceMetadata = resourceMetadata,
|
||||
};
|
||||
|
||||
await Options.Events.OnResourceMetadataRequest(context);
|
||||
|
|
@ -109,11 +159,16 @@ public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationO
|
|||
resourceMetadata = context.ResourceMetadata;
|
||||
}
|
||||
|
||||
if (resourceMetadata == null)
|
||||
if (resourceMetadata is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata or ensure context.ResourceMetadata is set inside McpAuthenticationOptions.Events.OnResourceMetadataRequest."
|
||||
);
|
||||
throw new InvalidOperationException("ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata or ensure context.ResourceMetadata is set inside McpAuthenticationOptions.Events.OnResourceMetadataRequest.");
|
||||
}
|
||||
|
||||
resourceMetadata.Resource ??= derivedResourceUri;
|
||||
|
||||
if (resourceMetadata.Resource is null)
|
||||
{
|
||||
throw new InvalidOperationException("ResourceMetadata.Resource could not be determined. Please set McpAuthenticationOptions.ResourceMetadata.Resource or avoid setting a custom McpAuthenticationOptions.ResourceMetadataUri.");
|
||||
}
|
||||
|
||||
await Results.Json(resourceMetadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))).ExecuteAsync(Context);
|
||||
|
|
@ -142,7 +197,7 @@ public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationO
|
|||
return base.HandleChallengeAsync(properties);
|
||||
}
|
||||
|
||||
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata)
|
||||
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata, Uri? derivedResourceUri = null)
|
||||
{
|
||||
if (resourceMetadata is null)
|
||||
{
|
||||
|
|
@ -151,7 +206,7 @@ public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationO
|
|||
|
||||
return new ProtectedResourceMetadata
|
||||
{
|
||||
Resource = resourceMetadata.Resource,
|
||||
Resource = resourceMetadata.Resource ?? derivedResourceUri,
|
||||
AuthorizationServers = [.. resourceMetadata.AuthorizationServers],
|
||||
BearerMethodsSupported = [.. resourceMetadata.BearerMethodsSupported],
|
||||
ScopesSupported = [.. resourceMetadata.ScopesSupported],
|
||||
|
|
@ -168,4 +223,9 @@ public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationO
|
|||
};
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Resource metadata request host did not match configured host '{ConfiguredHost}'.")]
|
||||
private static partial void LogResourceMetadataHostMismatch(ILogger logger, string configuredHost);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Resource metadata request scheme did not match configured scheme '{ConfiguredScheme}'.")]
|
||||
private static partial void LogResourceMetadataSchemeMismatch(ILogger logger, string configuredScheme);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ namespace ModelContextProtocol.AspNetCore.Authentication;
|
|||
/// </summary>
|
||||
public class McpAuthenticationOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
private static readonly Uri DefaultResourceMetadataUri = new("/.well-known/oauth-protected-resource", UriKind.Relative);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpAuthenticationOptions"/> class.
|
||||
/// </summary>
|
||||
|
|
@ -17,7 +15,6 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions
|
|||
{
|
||||
// "Bearer" is JwtBearerDefaults.AuthenticationScheme, but we don't have a reference to the JwtBearer package here.
|
||||
ForwardAuthenticate = "Bearer";
|
||||
ResourceMetadataUri = DefaultResourceMetadataUri;
|
||||
Events = new McpAuthenticationEvents();
|
||||
}
|
||||
|
||||
|
|
@ -35,8 +32,10 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This URI is included in the WWW-Authenticate header when a 401 response is returned.
|
||||
/// When <see langword="null"/>, the handler automatically uses the default
|
||||
/// <c>/.well-known/oauth-protected-resource/<resource-path></c> endpoint that mirrors the requested resource path.
|
||||
/// </remarks>
|
||||
public Uri ResourceMetadataUri { get; set; }
|
||||
public Uri? ResourceMetadataUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protected resource metadata.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ internal sealed partial class ClientOAuthProvider
|
|||
/// The Bearer authentication scheme.
|
||||
/// </summary>
|
||||
private const string BearerScheme = "Bearer";
|
||||
private const string ProtectedResourceMetadataWellKnownPath = "/.well-known/oauth-protected-resource";
|
||||
|
||||
private static readonly string[] s_wellKnownPaths = [".well-known/openid-configuration", ".well-known/oauth-authorization-server"];
|
||||
|
||||
|
|
@ -43,7 +44,6 @@ internal sealed partial class ClientOAuthProvider
|
|||
|
||||
private string? _clientId;
|
||||
private string? _clientSecret;
|
||||
|
||||
private ITokenCache _tokenCache;
|
||||
private AuthorizationServerMetadata? _authServerMetadata;
|
||||
|
||||
|
|
@ -223,9 +223,11 @@ internal sealed partial class ClientOAuthProvider
|
|||
_authServerMetadata = authServerMetadata;
|
||||
|
||||
// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
|
||||
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
|
||||
|
||||
if (await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { } refreshToken })
|
||||
{
|
||||
var refreshedTokens = await RefreshTokenAsync(refreshToken, protectedResourceMetadata.Resource, authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||
var refreshedTokens = await RefreshTokenAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||
if (refreshedTokens is not null)
|
||||
{
|
||||
// A non-null result indicates the refresh succeeded and the new tokens have been stored.
|
||||
|
|
@ -375,6 +377,8 @@ internal sealed partial class ClientOAuthProvider
|
|||
AuthorizationServerMetadata authServerMetadata,
|
||||
string codeChallenge)
|
||||
{
|
||||
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
|
||||
|
||||
var queryParamsDictionary = new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = GetClientIdOrThrow(),
|
||||
|
|
@ -382,7 +386,7 @@ internal sealed partial class ClientOAuthProvider
|
|||
["response_type"] = "code",
|
||||
["code_challenge"] = codeChallenge,
|
||||
["code_challenge_method"] = "S256",
|
||||
["resource"] = protectedResourceMetadata.Resource.ToString(),
|
||||
["resource"] = resourceUri.ToString(),
|
||||
};
|
||||
|
||||
var scopesSupported = protectedResourceMetadata.ScopesSupported;
|
||||
|
|
@ -418,6 +422,8 @@ internal sealed partial class ClientOAuthProvider
|
|||
string codeVerifier,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
|
||||
|
||||
var requestContent = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "authorization_code",
|
||||
|
|
@ -426,7 +432,7 @@ internal sealed partial class ClientOAuthProvider
|
|||
["client_id"] = GetClientIdOrThrow(),
|
||||
["code_verifier"] = codeVerifier,
|
||||
["client_secret"] = _clientSecret ?? string.Empty,
|
||||
["resource"] = protectedResourceMetadata.Resource.ToString(),
|
||||
["resource"] = resourceUri.ToString(),
|
||||
});
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
|
||||
|
|
@ -469,6 +475,28 @@ internal sealed partial class ClientOAuthProvider
|
|||
return tokens;
|
||||
}
|
||||
|
||||
private static Uri BuildProtectedResourceMetadataUri(Uri resourceUri)
|
||||
{
|
||||
var builder = new UriBuilder(resourceUri)
|
||||
{
|
||||
Query = string.Empty,
|
||||
Fragment = string.Empty,
|
||||
};
|
||||
|
||||
var pathSuffix = resourceUri.AbsolutePath;
|
||||
if (pathSuffix.Length > 1)
|
||||
{
|
||||
pathSuffix = pathSuffix.TrimEnd('/');
|
||||
builder.Path = string.Concat(ProtectedResourceMetadataWellKnownPath, pathSuffix);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Path = ProtectedResourceMetadataWellKnownPath;
|
||||
}
|
||||
|
||||
return builder.Uri;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the protected resource metadata from the provided URL.
|
||||
/// </summary>
|
||||
|
|
@ -583,6 +611,16 @@ internal sealed partial class ClientOAuthProvider
|
|||
return string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static Uri GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
|
||||
{
|
||||
if (protectedResourceMetadata.Resource is null)
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse("Protected resource metadata did not include a 'resource' value.");
|
||||
}
|
||||
|
||||
return protectedResourceMetadata.Resource;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a URI for consistent comparison.
|
||||
/// </summary>
|
||||
|
|
@ -611,8 +649,7 @@ internal sealed partial class ClientOAuthProvider
|
|||
/// <param name="serverUrl">The server URL to verify against the resource metadata.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The resource metadata if the resource matches the server, otherwise throws an exception.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the response is not a 401, lacks a WWW-Authenticate header,
|
||||
/// lacks a resource_metadata parameter, the metadata can't be fetched, or the resource URI doesn't match the server URL.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the response is not a 401, the metadata can't be fetched, or the resource URI doesn't match the server URL.</exception>
|
||||
private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(HttpResponseMessage response, Uri serverUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized)
|
||||
|
|
@ -620,34 +657,42 @@ internal sealed partial class ClientOAuthProvider
|
|||
throw new InvalidOperationException($"Expected a 401 Unauthorized response, but received {(int)response.StatusCode} {response.StatusCode}");
|
||||
}
|
||||
|
||||
// Extract the WWW-Authenticate header
|
||||
Uri metadataUri;
|
||||
|
||||
if (response.Headers.WwwAuthenticate.Count == 0)
|
||||
{
|
||||
throw new McpException("The 401 response does not contain a WWW-Authenticate header");
|
||||
metadataUri = BuildProtectedResourceMetadataUri(serverUrl);
|
||||
LogMissingWwwAuthenticateHeader(metadataUri);
|
||||
}
|
||||
|
||||
// Look for the Bearer authentication scheme with resource_metadata parameter
|
||||
string? resourceMetadataUrl = null;
|
||||
foreach (var header in response.Headers.WwwAuthenticate)
|
||||
else
|
||||
{
|
||||
if (string.Equals(header.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(header.Parameter))
|
||||
// Look for the Bearer authentication scheme with resource_metadata parameter
|
||||
string? resourceMetadataUrl = null;
|
||||
foreach (var header in response.Headers.WwwAuthenticate)
|
||||
{
|
||||
resourceMetadataUrl = ParseWwwAuthenticateParameters(header.Parameter, "resource_metadata");
|
||||
if (resourceMetadataUrl != null)
|
||||
if (string.Equals(header.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(header.Parameter))
|
||||
{
|
||||
break;
|
||||
resourceMetadataUrl = ParseWwwAuthenticateParameters(header.Parameter, "resource_metadata");
|
||||
if (resourceMetadataUrl != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceMetadataUrl == null)
|
||||
{
|
||||
metadataUri = BuildProtectedResourceMetadataUri(serverUrl);
|
||||
LogMissingResourceMetadataParameter(metadataUri);
|
||||
}
|
||||
else
|
||||
{
|
||||
metadataUri = new(resourceMetadataUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceMetadataUrl == null)
|
||||
{
|
||||
throw new McpException("The WWW-Authenticate header does not contain a resource_metadata parameter");
|
||||
}
|
||||
|
||||
Uri metadataUri = new(resourceMetadataUrl);
|
||||
var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new McpException($"Failed to fetch resource metadata from {resourceMetadataUrl}");
|
||||
?? throw new McpException($"Failed to fetch resource metadata from {metadataUri}");
|
||||
|
||||
// Per RFC: The resource value must be identical to the URL that the client used
|
||||
// to make the request to the resource server
|
||||
|
|
@ -760,4 +805,10 @@ internal sealed partial class ClientOAuthProvider
|
|||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = "Validating resource metadata against original server URL: {ServerUrl}")]
|
||||
partial void LogValidatingResourceMetadata(Uri serverUrl);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = "WWW-Authenticate header missing. Falling back to resource metadata at {MetadataUri}")]
|
||||
partial void LogMissingWwwAuthenticateHeader(Uri metadataUri);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = "WWW-Authenticate header missing resource_metadata parameter. Falling back to {MetadataUri}")]
|
||||
partial void LogMissingResourceMetadataParameter(Uri metadataUri);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ public sealed class ProtectedResourceMetadata
|
|||
/// The protected resource's resource identifier.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// REQUIRED.
|
||||
/// OPTIONAL. When omitted, the MCP authentication handler infers the resource URI from the incoming request only when serving
|
||||
/// the default <c>/.well-known/oauth-protected-resource</c> endpoint. If a custom <c>ResourceMetadataUri</c> is configured,
|
||||
/// <b>Resource</b> must be explicitly set. Automatic inference only works with the default endpoint pattern.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("resource")]
|
||||
public required Uri Resource { get; set; }
|
||||
public Uri? Resource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of authorization server URIs.
|
||||
|
|
|
|||
|
|
@ -104,17 +104,11 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
McpJsonUtilities.JsonContext.Default.ElicitResult);
|
||||
|
||||
_options.Capabilities ??= new();
|
||||
if (_options.Capabilities.Elicitation is null)
|
||||
_options.Capabilities.Elicitation ??= new();
|
||||
if (_options.Capabilities.Elicitation.Form is null &&
|
||||
_options.Capabilities.Elicitation.Url is null)
|
||||
{
|
||||
// Default to supporting only form mode if not explicitly configured
|
||||
_options.Capabilities.Elicitation = new()
|
||||
{
|
||||
Form = new(),
|
||||
};
|
||||
}
|
||||
else if (_options.Capabilities.Elicitation.Form is null && _options.Capabilities.Elicitation.Url is null)
|
||||
{
|
||||
// If ElicitationCapability is set but both modes are null, default to form mode for backward compatibility
|
||||
// If both modes are null, default to form mode for backward compatibility.
|
||||
_options.Capabilities.Elicitation.Form = new();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,8 +70,6 @@ public sealed class ElicitationCapability
|
|||
}
|
||||
|
||||
var capability = new ElicitationCapability();
|
||||
bool hasForm = false;
|
||||
bool hasUrl = false;
|
||||
|
||||
foreach (var property in document.RootElement.EnumerateObject())
|
||||
{
|
||||
|
|
@ -80,19 +78,18 @@ public sealed class ElicitationCapability
|
|||
capability.Form = property.Value.ValueKind == JsonValueKind.Null
|
||||
? null
|
||||
: capability.Form ?? new FormElicitationCapability();
|
||||
hasForm = true;
|
||||
}
|
||||
else if (property.NameEquals("url"))
|
||||
{
|
||||
capability.Url = property.Value.ValueKind == JsonValueKind.Null
|
||||
? null
|
||||
: capability.Url ?? new UrlElicitationCapability();
|
||||
hasUrl = capability.Url is not null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasForm && !hasUrl)
|
||||
if (capability.Form is null && capability.Url is null)
|
||||
{
|
||||
// If both modes are null, default to form mode for backward compatibility.
|
||||
capability.Form = new FormElicitationCapability();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue