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
19 changed files with 497 additions and 134 deletions
|
|
@ -56,7 +56,6 @@ builder.Services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
options.ResourceMetadata = new()
|
options.ResourceMetadata = new()
|
||||||
{
|
{
|
||||||
Resource = new Uri(serverUrl),
|
|
||||||
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
|
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
|
||||||
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
|
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
|
||||||
ScopesSupported = ["mcp:tools"],
|
ScopesSupported = ["mcp:tools"],
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,11 @@ namespace ModelContextProtocol.AspNetCore.Authentication;
|
||||||
/// Represents an authentication handler for MCP protocol that adds resource metadata to challenge responses
|
/// Represents an authentication handler for MCP protocol that adds resource metadata to challenge responses
|
||||||
/// and handles resource metadata endpoint requests.
|
/// and handles resource metadata endpoint requests.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="McpAuthenticationHandler"/> class.
|
/// Initializes a new instance of the <see cref="McpAuthenticationHandler"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -25,20 +28,19 @@ public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationO
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> HandleRequestAsync()
|
public Task<bool> HandleRequestAsync()
|
||||||
{
|
{
|
||||||
// Check if the request is for the resource metadata endpoint
|
if (Options.ResourceMetadataUri is Uri configuredUri)
|
||||||
string requestPath = Request.Path.Value ?? string.Empty;
|
|
||||||
|
|
||||||
string expectedMetadataPath = Options.ResourceMetadataUri?.ToString() ?? string.Empty;
|
|
||||||
if (Options.ResourceMetadataUri != null && !Options.ResourceMetadataUri.IsAbsoluteUri)
|
|
||||||
{
|
{
|
||||||
// For relative URIs, it's just the path component.
|
return HandleConfiguredResourceMetadataRequestAsync(configuredUri);
|
||||||
expectedMetadataPath = Options.ResourceMetadataUri.OriginalString;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the path doesn't match, let the request continue through the pipeline
|
return HandleDefaultResourceMetadataRequestAsync();
|
||||||
if (!string.Equals(requestPath, expectedMetadataPath, StringComparison.OrdinalIgnoreCase))
|
}
|
||||||
|
|
||||||
|
private async Task<bool> HandleConfiguredResourceMetadataRequestAsync(Uri resourceMetadataUri)
|
||||||
|
{
|
||||||
|
if (!IsConfiguredEndpointRequest(resourceMetadataUri))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -46,46 +48,94 @@ public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationO
|
||||||
return await HandleResourceMetadataRequestAsync();
|
return await HandleResourceMetadataRequestAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private async Task<bool> HandleDefaultResourceMetadataRequestAsync()
|
||||||
/// Gets the base URL from the current request, including scheme, host, and path base.
|
{
|
||||||
/// </summary>
|
if (!Request.Path.StartsWithSegments(DefaultResourceMetadataPrefix, out var resourceSuffix))
|
||||||
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
|
{
|
||||||
|
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>
|
/// <summary>
|
||||||
/// Gets the absolute URI for the resource metadata endpoint.
|
/// Gets the absolute URI for the resource metadata endpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private string GetAbsoluteResourceMetadataUri()
|
private string GetAbsoluteResourceMetadataUri()
|
||||||
{
|
{
|
||||||
var resourceMetadataUri = Options.ResourceMetadataUri;
|
if (Options.ResourceMetadataUri is Uri resourceMetadataUri)
|
||||||
|
|
||||||
string currentPath = resourceMetadataUri?.ToString() ?? string.Empty;
|
|
||||||
|
|
||||||
if (resourceMetadataUri != null && resourceMetadataUri.IsAbsoluteUri)
|
|
||||||
{
|
{
|
||||||
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
|
return $"{Request.Scheme}://{Request.Host.ToUriComponent()}{Request.PathBase}{DefaultResourceMetadataPath}{Request.Path}";
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
if (Options.Events.OnResourceMetadataRequest is not null)
|
||||||
{
|
{
|
||||||
var context = new ResourceMetadataRequestContext(Request.HttpContext, Scheme, Options)
|
var context = new ResourceMetadataRequestContext(Request.HttpContext, Scheme, Options)
|
||||||
{
|
{
|
||||||
ResourceMetadata = CloneResourceMetadata(resourceMetadata),
|
ResourceMetadata = resourceMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
await Options.Events.OnResourceMetadataRequest(context);
|
await Options.Events.OnResourceMetadataRequest(context);
|
||||||
|
|
@ -109,11 +159,16 @@ public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationO
|
||||||
resourceMetadata = context.ResourceMetadata;
|
resourceMetadata = context.ResourceMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceMetadata == null)
|
if (resourceMetadata is null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException("ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata or ensure context.ResourceMetadata is set inside McpAuthenticationOptions.Events.OnResourceMetadataRequest.");
|
||||||
"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);
|
await Results.Json(resourceMetadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))).ExecuteAsync(Context);
|
||||||
|
|
@ -142,7 +197,7 @@ public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationO
|
||||||
return base.HandleChallengeAsync(properties);
|
return base.HandleChallengeAsync(properties);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata)
|
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata, Uri? derivedResourceUri = null)
|
||||||
{
|
{
|
||||||
if (resourceMetadata is null)
|
if (resourceMetadata is null)
|
||||||
{
|
{
|
||||||
|
|
@ -151,7 +206,7 @@ public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationO
|
||||||
|
|
||||||
return new ProtectedResourceMetadata
|
return new ProtectedResourceMetadata
|
||||||
{
|
{
|
||||||
Resource = resourceMetadata.Resource,
|
Resource = resourceMetadata.Resource ?? derivedResourceUri,
|
||||||
AuthorizationServers = [.. resourceMetadata.AuthorizationServers],
|
AuthorizationServers = [.. resourceMetadata.AuthorizationServers],
|
||||||
BearerMethodsSupported = [.. resourceMetadata.BearerMethodsSupported],
|
BearerMethodsSupported = [.. resourceMetadata.BearerMethodsSupported],
|
||||||
ScopesSupported = [.. resourceMetadata.ScopesSupported],
|
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>
|
/// </summary>
|
||||||
public class McpAuthenticationOptions : AuthenticationSchemeOptions
|
public class McpAuthenticationOptions : AuthenticationSchemeOptions
|
||||||
{
|
{
|
||||||
private static readonly Uri DefaultResourceMetadataUri = new("/.well-known/oauth-protected-resource", UriKind.Relative);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="McpAuthenticationOptions"/> class.
|
/// Initializes a new instance of the <see cref="McpAuthenticationOptions"/> class.
|
||||||
/// </summary>
|
/// </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.
|
// "Bearer" is JwtBearerDefaults.AuthenticationScheme, but we don't have a reference to the JwtBearer package here.
|
||||||
ForwardAuthenticate = "Bearer";
|
ForwardAuthenticate = "Bearer";
|
||||||
ResourceMetadataUri = DefaultResourceMetadataUri;
|
|
||||||
Events = new McpAuthenticationEvents();
|
Events = new McpAuthenticationEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,8 +32,10 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This URI is included in the WWW-Authenticate header when a 401 response is returned.
|
/// 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>
|
/// </remarks>
|
||||||
public Uri ResourceMetadataUri { get; set; }
|
public Uri? ResourceMetadataUri { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the protected resource metadata.
|
/// Gets or sets the protected resource metadata.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ internal sealed partial class ClientOAuthProvider
|
||||||
/// The Bearer authentication scheme.
|
/// The Bearer authentication scheme.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const string BearerScheme = "Bearer";
|
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"];
|
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? _clientId;
|
||||||
private string? _clientSecret;
|
private string? _clientSecret;
|
||||||
|
|
||||||
private ITokenCache _tokenCache;
|
private ITokenCache _tokenCache;
|
||||||
private AuthorizationServerMetadata? _authServerMetadata;
|
private AuthorizationServerMetadata? _authServerMetadata;
|
||||||
|
|
||||||
|
|
@ -223,9 +223,11 @@ internal sealed partial class ClientOAuthProvider
|
||||||
_authServerMetadata = authServerMetadata;
|
_authServerMetadata = authServerMetadata;
|
||||||
|
|
||||||
// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
|
// 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 })
|
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)
|
if (refreshedTokens is not null)
|
||||||
{
|
{
|
||||||
// A non-null result indicates the refresh succeeded and the new tokens have been stored.
|
// 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,
|
AuthorizationServerMetadata authServerMetadata,
|
||||||
string codeChallenge)
|
string codeChallenge)
|
||||||
{
|
{
|
||||||
|
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
|
||||||
|
|
||||||
var queryParamsDictionary = new Dictionary<string, string>
|
var queryParamsDictionary = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["client_id"] = GetClientIdOrThrow(),
|
["client_id"] = GetClientIdOrThrow(),
|
||||||
|
|
@ -382,7 +386,7 @@ internal sealed partial class ClientOAuthProvider
|
||||||
["response_type"] = "code",
|
["response_type"] = "code",
|
||||||
["code_challenge"] = codeChallenge,
|
["code_challenge"] = codeChallenge,
|
||||||
["code_challenge_method"] = "S256",
|
["code_challenge_method"] = "S256",
|
||||||
["resource"] = protectedResourceMetadata.Resource.ToString(),
|
["resource"] = resourceUri.ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
var scopesSupported = protectedResourceMetadata.ScopesSupported;
|
var scopesSupported = protectedResourceMetadata.ScopesSupported;
|
||||||
|
|
@ -418,6 +422,8 @@ internal sealed partial class ClientOAuthProvider
|
||||||
string codeVerifier,
|
string codeVerifier,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
|
||||||
|
|
||||||
var requestContent = new FormUrlEncodedContent(new Dictionary<string, string>
|
var requestContent = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["grant_type"] = "authorization_code",
|
["grant_type"] = "authorization_code",
|
||||||
|
|
@ -426,7 +432,7 @@ internal sealed partial class ClientOAuthProvider
|
||||||
["client_id"] = GetClientIdOrThrow(),
|
["client_id"] = GetClientIdOrThrow(),
|
||||||
["code_verifier"] = codeVerifier,
|
["code_verifier"] = codeVerifier,
|
||||||
["client_secret"] = _clientSecret ?? string.Empty,
|
["client_secret"] = _clientSecret ?? string.Empty,
|
||||||
["resource"] = protectedResourceMetadata.Resource.ToString(),
|
["resource"] = resourceUri.ToString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
|
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
|
||||||
|
|
@ -469,6 +475,28 @@ internal sealed partial class ClientOAuthProvider
|
||||||
return tokens;
|
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>
|
/// <summary>
|
||||||
/// Fetches the protected resource metadata from the provided URL.
|
/// Fetches the protected resource metadata from the provided URL.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -583,6 +611,16 @@ internal sealed partial class ClientOAuthProvider
|
||||||
return string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase);
|
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>
|
/// <summary>
|
||||||
/// Normalizes a URI for consistent comparison.
|
/// Normalizes a URI for consistent comparison.
|
||||||
/// </summary>
|
/// </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="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>
|
/// <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>
|
/// <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,
|
/// <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>
|
||||||
/// lacks a resource_metadata parameter, 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)
|
private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(HttpResponseMessage response, Uri serverUrl, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized)
|
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}");
|
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)
|
if (response.Headers.WwwAuthenticate.Count == 0)
|
||||||
{
|
{
|
||||||
throw new McpException("The 401 response does not contain a WWW-Authenticate header");
|
metadataUri = BuildProtectedResourceMetadataUri(serverUrl);
|
||||||
|
LogMissingWwwAuthenticateHeader(metadataUri);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
// Look for the Bearer authentication scheme with resource_metadata parameter
|
|
||||||
string? resourceMetadataUrl = null;
|
|
||||||
foreach (var header in response.Headers.WwwAuthenticate)
|
|
||||||
{
|
{
|
||||||
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 (string.Equals(header.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(header.Parameter))
|
||||||
if (resourceMetadataUrl != null)
|
|
||||||
{
|
{
|
||||||
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)
|
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
|
// Per RFC: The resource value must be identical to the URL that the client used
|
||||||
// to make the request to the resource server
|
// 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}")]
|
[LoggerMessage(Level = LogLevel.Debug, Message = "Validating resource metadata against original server URL: {ServerUrl}")]
|
||||||
partial void LogValidatingResourceMetadata(Uri 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.
|
/// The protected resource's resource identifier.
|
||||||
/// </value>
|
/// </value>
|
||||||
/// <remarks>
|
/// <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>
|
/// </remarks>
|
||||||
[JsonPropertyName("resource")]
|
[JsonPropertyName("resource")]
|
||||||
public required Uri Resource { get; set; }
|
public Uri? Resource { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of authorization server URIs.
|
/// Gets or sets the list of authorization server URIs.
|
||||||
|
|
|
||||||
|
|
@ -104,17 +104,11 @@ internal sealed partial class McpClientImpl : McpClient
|
||||||
McpJsonUtilities.JsonContext.Default.ElicitResult);
|
McpJsonUtilities.JsonContext.Default.ElicitResult);
|
||||||
|
|
||||||
_options.Capabilities ??= new();
|
_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
|
// If both modes are null, default to form mode for backward compatibility.
|
||||||
_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
|
|
||||||
_options.Capabilities.Elicitation.Form = new();
|
_options.Capabilities.Elicitation.Form = new();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,6 @@ public sealed class ElicitationCapability
|
||||||
}
|
}
|
||||||
|
|
||||||
var capability = new ElicitationCapability();
|
var capability = new ElicitationCapability();
|
||||||
bool hasForm = false;
|
|
||||||
bool hasUrl = false;
|
|
||||||
|
|
||||||
foreach (var property in document.RootElement.EnumerateObject())
|
foreach (var property in document.RootElement.EnumerateObject())
|
||||||
{
|
{
|
||||||
|
|
@ -80,19 +78,18 @@ public sealed class ElicitationCapability
|
||||||
capability.Form = property.Value.ValueKind == JsonValueKind.Null
|
capability.Form = property.Value.ValueKind == JsonValueKind.Null
|
||||||
? null
|
? null
|
||||||
: capability.Form ?? new FormElicitationCapability();
|
: capability.Form ?? new FormElicitationCapability();
|
||||||
hasForm = true;
|
|
||||||
}
|
}
|
||||||
else if (property.NameEquals("url"))
|
else if (property.NameEquals("url"))
|
||||||
{
|
{
|
||||||
capability.Url = property.Value.ValueKind == JsonValueKind.Null
|
capability.Url = property.Value.ValueKind == JsonValueKind.Null
|
||||||
? null
|
? null
|
||||||
: capability.Url ?? new UrlElicitationCapability();
|
: 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();
|
capability.Form = new FormElicitationCapability();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,15 @@ public class LoggedTest : IDisposable
|
||||||
LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
|
LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
|
||||||
{
|
{
|
||||||
builder.AddProvider(XunitLoggerProvider);
|
builder.AddProvider(XunitLoggerProvider);
|
||||||
|
builder.AddProvider(MockLoggerProvider);
|
||||||
|
builder.SetMinimumLevel(LogLevel.Debug);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ITestOutputHelper TestOutputHelper => _delegatingTestOutputHelper;
|
public ITestOutputHelper TestOutputHelper => _delegatingTestOutputHelper;
|
||||||
public ILoggerFactory LoggerFactory { get; set; }
|
public ILoggerFactory LoggerFactory { get; set; }
|
||||||
public ILoggerProvider XunitLoggerProvider { get; }
|
public ILoggerProvider XunitLoggerProvider { get; }
|
||||||
|
public MockLoggerProvider MockLoggerProvider { get; } = new();
|
||||||
|
|
||||||
public virtual void Dispose()
|
public virtual void Dispose()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ using System.Collections.Concurrent;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Tests.Utils;
|
namespace ModelContextProtocol.Tests.Utils;
|
||||||
|
|
||||||
public class MockLoggerProvider() : ILoggerProvider
|
public class MockLoggerProvider : ILoggerProvider
|
||||||
{
|
{
|
||||||
public ConcurrentQueue<(string Category, LogLevel LogLevel, EventId EventId, string Message, Exception? Exception)> LogMessages { get; } = [];
|
public ConcurrentQueue<(string Category, LogLevel LogLevel, EventId EventId, string Message, Exception? Exception)> LogMessages { get; } = [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
|
public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
|
||||||
{
|
{
|
||||||
private readonly MockLoggerProvider _mockLoggerProvider = new();
|
|
||||||
|
|
||||||
private async Task<McpClient> ConnectAsync()
|
private async Task<McpClient> ConnectAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -273,7 +272,6 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListTools_WithoutAuthFilters_ThrowsInvalidOperationException()
|
public async Task ListTools_WithoutAuthFilters_ThrowsInvalidOperationException()
|
||||||
{
|
{
|
||||||
_mockLoggerProvider.LogMessages.Clear();
|
|
||||||
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithTools<AuthorizationTestTools>());
|
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithTools<AuthorizationTestTools>());
|
||||||
var client = await ConnectAsync();
|
var client = await ConnectAsync();
|
||||||
|
|
||||||
|
|
@ -281,7 +279,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
|
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
|
||||||
Assert.Contains(_mockLoggerProvider.LogMessages, log =>
|
Assert.Contains(MockLoggerProvider.LogMessages, log =>
|
||||||
log.LogLevel == LogLevel.Warning &&
|
log.LogLevel == LogLevel.Warning &&
|
||||||
log.Exception is InvalidOperationException &&
|
log.Exception is InvalidOperationException &&
|
||||||
log.Exception.Message.Contains("Authorization filter was not invoked for tools/list operation") &&
|
log.Exception.Message.Contains("Authorization filter was not invoked for tools/list operation") &&
|
||||||
|
|
@ -291,7 +289,6 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CallTool_WithoutAuthFilters_ReturnsError()
|
public async Task CallTool_WithoutAuthFilters_ReturnsError()
|
||||||
{
|
{
|
||||||
_mockLoggerProvider.LogMessages.Clear();
|
|
||||||
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithTools<AuthorizationTestTools>());
|
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithTools<AuthorizationTestTools>());
|
||||||
var client = await ConnectAsync();
|
var client = await ConnectAsync();
|
||||||
|
|
||||||
|
|
@ -304,7 +301,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
|
|
||||||
var errorContent = Assert.IsType<TextContentBlock>(Assert.Single(toolResult.Content));
|
var errorContent = Assert.IsType<TextContentBlock>(Assert.Single(toolResult.Content));
|
||||||
Assert.Equal("An error occurred invoking 'authorized_tool'.", errorContent.Text);
|
Assert.Equal("An error occurred invoking 'authorized_tool'.", errorContent.Text);
|
||||||
Assert.Contains(_mockLoggerProvider.LogMessages, log =>
|
Assert.Contains(MockLoggerProvider.LogMessages, log =>
|
||||||
log.LogLevel == LogLevel.Error &&
|
log.LogLevel == LogLevel.Error &&
|
||||||
log.Exception is InvalidOperationException &&
|
log.Exception is InvalidOperationException &&
|
||||||
log.Exception.Message.Contains("Authorization filter was not invoked for tools/call operation") &&
|
log.Exception.Message.Contains("Authorization filter was not invoked for tools/call operation") &&
|
||||||
|
|
@ -314,7 +311,6 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListPrompts_WithoutAuthFilters_ThrowsInvalidOperationException()
|
public async Task ListPrompts_WithoutAuthFilters_ThrowsInvalidOperationException()
|
||||||
{
|
{
|
||||||
_mockLoggerProvider.LogMessages.Clear();
|
|
||||||
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithPrompts<AuthorizationTestPrompts>());
|
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithPrompts<AuthorizationTestPrompts>());
|
||||||
var client = await ConnectAsync();
|
var client = await ConnectAsync();
|
||||||
|
|
||||||
|
|
@ -322,7 +318,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken));
|
await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
|
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
|
||||||
Assert.Contains(_mockLoggerProvider.LogMessages, log =>
|
Assert.Contains(MockLoggerProvider.LogMessages, log =>
|
||||||
log.LogLevel == LogLevel.Warning &&
|
log.LogLevel == LogLevel.Warning &&
|
||||||
log.Exception is InvalidOperationException &&
|
log.Exception is InvalidOperationException &&
|
||||||
log.Exception.Message.Contains("Authorization filter was not invoked for prompts/list operation") &&
|
log.Exception.Message.Contains("Authorization filter was not invoked for prompts/list operation") &&
|
||||||
|
|
@ -332,7 +328,6 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPrompt_WithoutAuthFilters_ThrowsInvalidOperationException()
|
public async Task GetPrompt_WithoutAuthFilters_ThrowsInvalidOperationException()
|
||||||
{
|
{
|
||||||
_mockLoggerProvider.LogMessages.Clear();
|
|
||||||
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithPrompts<AuthorizationTestPrompts>());
|
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithPrompts<AuthorizationTestPrompts>());
|
||||||
var client = await ConnectAsync();
|
var client = await ConnectAsync();
|
||||||
|
|
||||||
|
|
@ -343,7 +338,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
cancellationToken: TestContext.Current.CancellationToken));
|
cancellationToken: TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
|
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
|
||||||
Assert.Contains(_mockLoggerProvider.LogMessages, log =>
|
Assert.Contains(MockLoggerProvider.LogMessages, log =>
|
||||||
log.LogLevel == LogLevel.Warning &&
|
log.LogLevel == LogLevel.Warning &&
|
||||||
log.Exception is InvalidOperationException &&
|
log.Exception is InvalidOperationException &&
|
||||||
log.Exception.Message.Contains("Authorization filter was not invoked for prompts/get operation") &&
|
log.Exception.Message.Contains("Authorization filter was not invoked for prompts/get operation") &&
|
||||||
|
|
@ -353,7 +348,6 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListResources_WithoutAuthFilters_ThrowsInvalidOperationException()
|
public async Task ListResources_WithoutAuthFilters_ThrowsInvalidOperationException()
|
||||||
{
|
{
|
||||||
_mockLoggerProvider.LogMessages.Clear();
|
|
||||||
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources<AuthorizationTestResources>());
|
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources<AuthorizationTestResources>());
|
||||||
var client = await ConnectAsync();
|
var client = await ConnectAsync();
|
||||||
|
|
||||||
|
|
@ -361,7 +355,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken));
|
await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
|
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
|
||||||
Assert.Contains(_mockLoggerProvider.LogMessages, log =>
|
Assert.Contains(MockLoggerProvider.LogMessages, log =>
|
||||||
log.LogLevel == LogLevel.Warning &&
|
log.LogLevel == LogLevel.Warning &&
|
||||||
log.Exception is InvalidOperationException &&
|
log.Exception is InvalidOperationException &&
|
||||||
log.Exception.Message.Contains("Authorization filter was not invoked for resources/list operation") &&
|
log.Exception.Message.Contains("Authorization filter was not invoked for resources/list operation") &&
|
||||||
|
|
@ -371,7 +365,6 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadResource_WithoutAuthFilters_ThrowsInvalidOperationException()
|
public async Task ReadResource_WithoutAuthFilters_ThrowsInvalidOperationException()
|
||||||
{
|
{
|
||||||
_mockLoggerProvider.LogMessages.Clear();
|
|
||||||
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources<AuthorizationTestResources>());
|
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources<AuthorizationTestResources>());
|
||||||
var client = await ConnectAsync();
|
var client = await ConnectAsync();
|
||||||
|
|
||||||
|
|
@ -381,7 +374,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
cancellationToken: TestContext.Current.CancellationToken));
|
cancellationToken: TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
|
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
|
||||||
Assert.Contains(_mockLoggerProvider.LogMessages, log =>
|
Assert.Contains(MockLoggerProvider.LogMessages, log =>
|
||||||
log.LogLevel == LogLevel.Warning &&
|
log.LogLevel == LogLevel.Warning &&
|
||||||
log.Exception is InvalidOperationException &&
|
log.Exception is InvalidOperationException &&
|
||||||
log.Exception.Message.Contains("Authorization filter was not invoked for resources/read operation") &&
|
log.Exception.Message.Contains("Authorization filter was not invoked for resources/read operation") &&
|
||||||
|
|
@ -391,7 +384,6 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListResourceTemplates_WithoutAuthFilters_ThrowsInvalidOperationException()
|
public async Task ListResourceTemplates_WithoutAuthFilters_ThrowsInvalidOperationException()
|
||||||
{
|
{
|
||||||
_mockLoggerProvider.LogMessages.Clear();
|
|
||||||
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources<AuthorizationTestResources>());
|
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources<AuthorizationTestResources>());
|
||||||
var client = await ConnectAsync();
|
var client = await ConnectAsync();
|
||||||
|
|
||||||
|
|
@ -399,7 +391,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken));
|
await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
|
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
|
||||||
Assert.Contains(_mockLoggerProvider.LogMessages, log =>
|
Assert.Contains(MockLoggerProvider.LogMessages, log =>
|
||||||
log.LogLevel == LogLevel.Warning &&
|
log.LogLevel == LogLevel.Warning &&
|
||||||
log.Exception is InvalidOperationException &&
|
log.Exception is InvalidOperationException &&
|
||||||
log.Exception.Message.Contains("Authorization filter was not invoked for resources/templates/list operation") &&
|
log.Exception.Message.Contains("Authorization filter was not invoked for resources/templates/list operation") &&
|
||||||
|
|
@ -412,7 +404,6 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
configure(mcpServerBuilder);
|
configure(mcpServerBuilder);
|
||||||
|
|
||||||
Builder.Services.AddAuthorization();
|
Builder.Services.AddAuthorization();
|
||||||
Builder.Services.AddSingleton<ILoggerProvider>(_mockLoggerProvider);
|
|
||||||
|
|
||||||
var app = Builder.Build();
|
var app = Builder.Build();
|
||||||
|
|
||||||
|
|
@ -439,7 +430,6 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
|
||||||
configure(mcpServerBuilder);
|
configure(mcpServerBuilder);
|
||||||
|
|
||||||
Builder.Services.AddAuthorization();
|
Builder.Services.AddAuthorization();
|
||||||
Builder.Services.AddSingleton<ILoggerProvider>(_mockLoggerProvider);
|
|
||||||
|
|
||||||
var app = Builder.Build();
|
var app = Builder.Build();
|
||||||
app.MapMcp();
|
app.MapMcp();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||||
using ModelContextProtocol.Client;
|
using ModelContextProtocol.Client;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using ModelContextProtocol.Tests.Utils;
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
@ -149,15 +147,8 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
|
||||||
|
|
||||||
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<SamplingRegressionTools>();
|
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<SamplingRegressionTools>();
|
||||||
|
|
||||||
var mockLoggerProvider = new MockLoggerProvider();
|
|
||||||
Builder.Logging.AddProvider(mockLoggerProvider);
|
|
||||||
Builder.Logging.SetMinimumLevel(LogLevel.Debug);
|
|
||||||
|
|
||||||
await using var app = Builder.Build();
|
await using var app = Builder.Build();
|
||||||
|
|
||||||
// Reset the LoggerFactory used by the client to use the MockLoggerProvider as well.
|
|
||||||
LoggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
|
|
||||||
|
|
||||||
app.MapMcp();
|
app.MapMcp();
|
||||||
|
|
||||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
@ -202,11 +193,11 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
|
||||||
|
|
||||||
// Verify that the tool call and the sampling request both used the same ID to ensure we cover against regressions.
|
// Verify that the tool call and the sampling request both used the same ID to ensure we cover against regressions.
|
||||||
// https://github.com/modelcontextprotocol/csharp-sdk/issues/464
|
// https://github.com/modelcontextprotocol/csharp-sdk/issues/464
|
||||||
Assert.Single(mockLoggerProvider.LogMessages, m =>
|
Assert.Single(MockLoggerProvider.LogMessages, m =>
|
||||||
m.Category == "ModelContextProtocol.Client.McpClient" &&
|
m.Category == "ModelContextProtocol.Client.McpClient" &&
|
||||||
m.Message.Contains("request '2' for method 'tools/call'"));
|
m.Message.Contains("request '2' for method 'tools/call'"));
|
||||||
|
|
||||||
Assert.Single(mockLoggerProvider.LogMessages, m =>
|
Assert.Single(MockLoggerProvider.LogMessages, m =>
|
||||||
m.Category == "ModelContextProtocol.Server.McpServer" &&
|
m.Category == "ModelContextProtocol.Server.McpServer" &&
|
||||||
m.Message.Contains("request '2' for method 'sampling/createMessage'"));
|
m.Message.Contains("request '2' for method 'sampling/createMessage'"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using ModelContextProtocol.AspNetCore.Authentication;
|
using ModelContextProtocol.AspNetCore.Authentication;
|
||||||
using ModelContextProtocol.Authentication;
|
using ModelContextProtocol.Authentication;
|
||||||
using ModelContextProtocol.Client;
|
using ModelContextProtocol.Client;
|
||||||
|
|
@ -283,6 +284,71 @@ public class AuthTests : OAuthTestBase
|
||||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CanAuthenticate_WithoutResourceInWwwAuthenticateHeader()
|
||||||
|
{
|
||||||
|
await using var app = await StartMcpServerAsync(authScheme: JwtBearerDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CanAuthenticate_WithoutResourceInWwwAuthenticateHeader_WithPathSuffix()
|
||||||
|
{
|
||||||
|
const string serverPath = "/mcp";
|
||||||
|
await using var app = await StartMcpServerAsync(serverPath, authScheme: JwtBearerDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
await using var transport = new HttpClientTransport(new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri($"{McpServerUrl}{serverPath}"),
|
||||||
|
OAuth = new()
|
||||||
|
{
|
||||||
|
ClientId = "demo-client",
|
||||||
|
ClientSecret = "demo-secret",
|
||||||
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JwtBearerChallenge_DoesNotIncludeResourceMetadata()
|
||||||
|
{
|
||||||
|
await using var app = await StartMcpServerAsync(authScheme: JwtBearerDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
using var unauthorizedResponse = await HttpClient.GetAsync(McpServerUrl, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedResponse.StatusCode);
|
||||||
|
|
||||||
|
var headerFound = false;
|
||||||
|
foreach (var header in unauthorizedResponse.Headers.WwwAuthenticate)
|
||||||
|
{
|
||||||
|
headerFound = true;
|
||||||
|
Assert.Equal("Bearer", header.Scheme);
|
||||||
|
Assert.True(header.Parameter is null || !header.Parameter.Contains("resource_metadata", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(headerFound);
|
||||||
|
|
||||||
|
using var metadataResponse = await HttpClient.GetAsync(new Uri("/.well-known/oauth-protected-resource", UriKind.Relative), TestContext.Current.CancellationToken);
|
||||||
|
metadataResponse.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CloneResourceMetadataClonesAllProperties()
|
public void CloneResourceMetadataClonesAllProperties()
|
||||||
{
|
{
|
||||||
|
|
@ -312,7 +378,7 @@ public class AuthTests : OAuthTestBase
|
||||||
var cloneMethod = handlerType.GetMethod("CloneResourceMetadata", BindingFlags.Static | BindingFlags.NonPublic);
|
var cloneMethod = handlerType.GetMethod("CloneResourceMetadata", BindingFlags.Static | BindingFlags.NonPublic);
|
||||||
Assert.NotNull(cloneMethod);
|
Assert.NotNull(cloneMethod);
|
||||||
|
|
||||||
var clonedMetadata = (ProtectedResourceMetadata?)cloneMethod.Invoke(null, [metadata]);
|
var clonedMetadata = (ProtectedResourceMetadata?)cloneMethod.Invoke(null, [metadata, null]);
|
||||||
Assert.NotNull(clonedMetadata);
|
Assert.NotNull(clonedMetadata);
|
||||||
|
|
||||||
// Ensure the cloned metadata is not the same instance
|
// Ensure the cloned metadata is not the same instance
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ModelContextProtocol.AspNetCore.Authentication;
|
||||||
|
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||||
|
using ModelContextProtocol.Authentication;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
|
||||||
|
|
||||||
|
public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Challenge_WithRelativeResourceMetadataUri_SetsAbsoluteUrl()
|
||||||
|
{
|
||||||
|
const string metadataPath = "/.well-known/custom-relative";
|
||||||
|
|
||||||
|
await using var app = await StartAuthenticationServerAsync(options =>
|
||||||
|
{
|
||||||
|
options.ResourceMetadataUri = new Uri(metadataPath, UriKind.Relative);
|
||||||
|
options.ResourceMetadata!.Resource = new Uri("http://localhost:5000/challenge");
|
||||||
|
});
|
||||||
|
|
||||||
|
using var challengeResponse = await HttpClient.GetAsync(new Uri("/challenge", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, challengeResponse.StatusCode);
|
||||||
|
var header = Assert.Single(challengeResponse.Headers.WwwAuthenticate);
|
||||||
|
Assert.Equal("Bearer", header.Scheme);
|
||||||
|
Assert.Contains($"resource_metadata=\"http://localhost:5000{metadataPath}\"", header.Parameter);
|
||||||
|
|
||||||
|
using var metadataResponse = await HttpClient.GetAsync(new Uri(metadataPath, UriKind.Relative), TestContext.Current.CancellationToken);
|
||||||
|
metadataResponse.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MetadataRequest_CustomResourceMetadataUriWithoutResource_ThrowsInvalidOperationException()
|
||||||
|
{
|
||||||
|
const string metadataPath = "/.well-known/custom-metadata";
|
||||||
|
|
||||||
|
await using var app = await StartAuthenticationServerAsync(options =>
|
||||||
|
{
|
||||||
|
options.ResourceMetadataUri = new Uri(metadataPath, UriKind.Relative);
|
||||||
|
});
|
||||||
|
|
||||||
|
using var response = await HttpClient.GetAsync(new Uri(metadataPath, UriKind.Relative), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||||
|
Assert.Contains(MockLoggerProvider.LogMessages, log =>
|
||||||
|
log.LogLevel == LogLevel.Error &&
|
||||||
|
log.Exception is InvalidOperationException &&
|
||||||
|
log.Exception.Message.Contains("ResourceMetadata.Resource could not be determined", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Challenge_WithAbsoluteResourceMetadataUri_SetsConfiguredUrl()
|
||||||
|
{
|
||||||
|
var metadataUri = new Uri("http://localhost:5000/.well-known/custom-absolute");
|
||||||
|
|
||||||
|
await using var app = await StartAuthenticationServerAsync(options =>
|
||||||
|
{
|
||||||
|
options.ResourceMetadataUri = metadataUri;
|
||||||
|
options.ResourceMetadata!.Resource = new Uri("http://localhost:5000/challenge");
|
||||||
|
});
|
||||||
|
|
||||||
|
using var challengeResponse = await HttpClient.GetAsync(new Uri("/challenge", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, challengeResponse.StatusCode);
|
||||||
|
var header = Assert.Single(challengeResponse.Headers.WwwAuthenticate);
|
||||||
|
Assert.Equal("Bearer", header.Scheme);
|
||||||
|
Assert.Contains($"resource_metadata=\"{metadataUri}\"", header.Parameter);
|
||||||
|
|
||||||
|
using var metadataResponse = await HttpClient.GetAsync(metadataUri, TestContext.Current.CancellationToken);
|
||||||
|
metadataResponse.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MetadataRequest_WithHostMismatch_LogsWarning()
|
||||||
|
{
|
||||||
|
var metadataUri = new Uri("http://expected-host:5000/.well-known/host-mismatch");
|
||||||
|
|
||||||
|
await using var app = await StartAuthenticationServerAsync(options =>
|
||||||
|
{
|
||||||
|
options.ResourceMetadataUri = metadataUri;
|
||||||
|
});
|
||||||
|
|
||||||
|
using var metadataRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost:5000/.well-known/host-mismatch"));
|
||||||
|
using var metadataResponse = await HttpClient.SendAsync(metadataRequest, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, metadataResponse.StatusCode);
|
||||||
|
Assert.Contains(MockLoggerProvider.LogMessages, log =>
|
||||||
|
log.LogLevel == LogLevel.Warning &&
|
||||||
|
log.Message.Contains("Resource metadata request host", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
log.Message.Contains("expected-host", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Challenge_WithDefaultMetadata_ComposesResourceSpecificEndpoint()
|
||||||
|
{
|
||||||
|
await using var app = await StartAuthenticationServerAsync();
|
||||||
|
|
||||||
|
using var challengeResponse = await HttpClient.GetAsync(new Uri("/resource/tools/list", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, challengeResponse.StatusCode);
|
||||||
|
var header = Assert.Single(challengeResponse.Headers.WwwAuthenticate);
|
||||||
|
Assert.Equal("Bearer", header.Scheme);
|
||||||
|
Assert.Contains("resource_metadata=\"http://localhost:5000/.well-known/oauth-protected-resource/resource/tools/list\"", header.Parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Challenge_WithDefaultMetadata_AndPathBase_ComposesResourceSpecificEndpoint()
|
||||||
|
{
|
||||||
|
await using var app = await StartAuthenticationServerAsync(pathBase: new PathString("/api"));
|
||||||
|
|
||||||
|
using var challengeResponse = await HttpClient.GetAsync(new Uri("/api/resource/tools/list", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, challengeResponse.StatusCode);
|
||||||
|
var header = Assert.Single(challengeResponse.Headers.WwwAuthenticate);
|
||||||
|
Assert.Equal("Bearer", header.Scheme);
|
||||||
|
Assert.Contains("resource_metadata=\"http://localhost:5000/api/.well-known/oauth-protected-resource/resource/tools/list\"", header.Parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MetadataRequest_DefaultEndpoint_SetsResourceFromSuffix()
|
||||||
|
{
|
||||||
|
await using var app = await StartAuthenticationServerAsync();
|
||||||
|
|
||||||
|
using var metadataResponse = await HttpClient.GetAsync(new Uri("/.well-known/oauth-protected-resource/resource/tools", UriKind.Relative), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
metadataResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var metadata = await metadataResponse.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
|
||||||
|
McpJsonUtilities.DefaultOptions,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(metadata);
|
||||||
|
Assert.Equal(new Uri("http://localhost:5000/resource/tools"), metadata!.Resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MetadataRequest_DefaultEndpoint_WithPathBase_SetsResourceFromSuffix()
|
||||||
|
{
|
||||||
|
await using var app = await StartAuthenticationServerAsync(pathBase: new PathString("/api"));
|
||||||
|
|
||||||
|
using var metadataResponse = await HttpClient.GetAsync(new Uri("/api/.well-known/oauth-protected-resource/resource/tools", UriKind.Relative), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
metadataResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var metadata = await metadataResponse.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
|
||||||
|
McpJsonUtilities.DefaultOptions,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(metadata);
|
||||||
|
Assert.Equal(new Uri("http://localhost:5000/api/resource/tools"), metadata!.Resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<WebApplication> StartAuthenticationServerAsync(Action<McpAuthenticationOptions>? configureOptions = null, PathString? pathBase = null)
|
||||||
|
{
|
||||||
|
var authenticationBuilder = Builder.Services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultScheme = McpAuthenticationDefaults.AuthenticationScheme;
|
||||||
|
options.DefaultAuthenticateScheme = McpAuthenticationDefaults.AuthenticationScheme;
|
||||||
|
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
|
||||||
|
});
|
||||||
|
|
||||||
|
authenticationBuilder.AddScheme<McpAuthenticationOptions, McpAuthenticationHandler>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||||
|
{
|
||||||
|
options.ResourceMetadata = new()
|
||||||
|
{
|
||||||
|
AuthorizationServers = [new Uri("https://localhost:7029")],
|
||||||
|
ScopesSupported = ["mcp:tools"],
|
||||||
|
};
|
||||||
|
configureOptions?.Invoke(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
authenticationBuilder.AddScheme<AuthenticationSchemeOptions, NoopBearerAuthenticationHandler>("Bearer", options => { });
|
||||||
|
|
||||||
|
Builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
var app = Builder.Build();
|
||||||
|
if (pathBase is PathString basePath && basePath.HasValue)
|
||||||
|
{
|
||||||
|
app.UsePathBase(basePath);
|
||||||
|
}
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.MapGet("/challenge", context => context.ChallengeAsync(McpAuthenticationDefaults.AuthenticationScheme));
|
||||||
|
app.MapGet("/resource/{*resourcePath}", context => context.ChallengeAsync(McpAuthenticationDefaults.AuthenticationScheme));
|
||||||
|
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopBearerAuthenticationHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder)
|
||||||
|
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync() => Task.FromResult(AuthenticateResult.NoResult());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
@ -55,7 +56,6 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
|
||||||
{
|
{
|
||||||
options.ResourceMetadata = new ProtectedResourceMetadata
|
options.ResourceMetadata = new ProtectedResourceMetadata
|
||||||
{
|
{
|
||||||
Resource = new Uri(McpServerUrl),
|
|
||||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
||||||
ScopesSupported = ["mcp:tools"]
|
ScopesSupported = ["mcp:tools"]
|
||||||
};
|
};
|
||||||
|
|
@ -82,10 +82,18 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async Task<WebApplication> StartMcpServerAsync()
|
protected async Task<WebApplication> StartMcpServerAsync(string path = "", string? authScheme = null)
|
||||||
{
|
{
|
||||||
|
Builder.Services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters.ValidAudience = $"{McpServerUrl}{path}";
|
||||||
|
});
|
||||||
|
|
||||||
var app = Builder.Build();
|
var app = Builder.Build();
|
||||||
app.MapMcp().RequireAuthorization();
|
app.MapMcp(path).RequireAuthorization(new AuthorizeAttribute
|
||||||
|
{
|
||||||
|
AuthenticationSchemes = authScheme
|
||||||
|
});
|
||||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using Microsoft.AspNetCore.Connections;
|
using Microsoft.AspNetCore.Connections;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using ModelContextProtocol.Tests.Utils;
|
using ModelContextProtocol.Tests.Utils;
|
||||||
|
|
||||||
namespace ModelContextProtocol.AspNetCore.Tests.Utils;
|
namespace ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||||
|
|
@ -16,7 +17,9 @@ public class KestrelInMemoryTest : LoggedTest
|
||||||
Builder.WebHost.UseKestrelCore();
|
Builder.WebHost.UseKestrelCore();
|
||||||
Builder.Services.AddRoutingCore();
|
Builder.Services.AddRoutingCore();
|
||||||
Builder.Services.AddLogging();
|
Builder.Services.AddLogging();
|
||||||
|
Builder.Services.AddSingleton<ILoggerProvider>(MockLoggerProvider);
|
||||||
Builder.Services.AddSingleton(XunitLoggerProvider);
|
Builder.Services.AddSingleton(XunitLoggerProvider);
|
||||||
|
Builder.Logging.SetMinimumLevel(LogLevel.Debug);
|
||||||
|
|
||||||
SocketsHttpHandler.ConnectCallback = (context, token) =>
|
SocketsHttpHandler.ConnectCallback = (context, token) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,11 @@ public sealed class Program
|
||||||
private static readonly string _clientMetadataDocumentUrl = $"{_url}/client-metadata/cimd-client.json";
|
private static readonly string _clientMetadataDocumentUrl = $"{_url}/client-metadata/cimd-client.json";
|
||||||
|
|
||||||
// Port 5000 is used by tests and port 7071 is used by the ProtectedMcpServer sample
|
// Port 5000 is used by tests and port 7071 is used by the ProtectedMcpServer sample
|
||||||
private static readonly string[] ValidResources = ["http://localhost:5000/", "http://localhost:7071/"];
|
private static readonly string[] ValidResources = [
|
||||||
|
"http://localhost:5000/",
|
||||||
|
"http://localhost:5000/mcp",
|
||||||
|
"http://localhost:7071/"
|
||||||
|
];
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, AuthorizationCodeInfo> _authCodes = new();
|
private readonly ConcurrentDictionary<string, AuthorizationCodeInfo> _authCodes = new();
|
||||||
private readonly ConcurrentDictionary<string, TokenInfo> _tokens = new();
|
private readonly ConcurrentDictionary<string, TokenInfo> _tokens = new();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using ModelContextProtocol.Client;
|
using ModelContextProtocol.Client;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
|
|
@ -22,6 +23,7 @@ public abstract class ClientServerTestBase : LoggedTest, IAsyncDisposable
|
||||||
ServiceCollection sc = new();
|
ServiceCollection sc = new();
|
||||||
sc.AddLogging();
|
sc.AddLogging();
|
||||||
sc.AddSingleton(XunitLoggerProvider);
|
sc.AddSingleton(XunitLoggerProvider);
|
||||||
|
sc.AddSingleton<ILoggerProvider>(MockLoggerProvider);
|
||||||
_builder = sc
|
_builder = sc
|
||||||
.AddMcpServer()
|
.AddMcpServer()
|
||||||
.WithStreamServerTransport(_clientToServerPipe.Reader.AsStream(), _serverToClientPipe.Writer.AsStream());
|
.WithStreamServerTransport(_clientToServerPipe.Reader.AsStream(), _serverToClientPipe.Writer.AsStream());
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging;
|
||||||
using ModelContextProtocol.Client;
|
using ModelContextProtocol.Client;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using ModelContextProtocol.Tests.Utils;
|
|
||||||
|
|
||||||
namespace ModelContextProtocol.Tests.Configuration;
|
namespace ModelContextProtocol.Tests.Configuration;
|
||||||
|
|
||||||
|
|
@ -14,8 +13,6 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
private MockLoggerProvider _mockLoggerProvider = new();
|
|
||||||
|
|
||||||
private static ILogger GetLogger(IServiceProvider? services, string categoryName)
|
private static ILogger GetLogger(IServiceProvider? services, string categoryName)
|
||||||
{
|
{
|
||||||
var loggerFactory = services?.GetRequiredService<ILoggerFactory>() ?? throw new InvalidOperationException("LoggerFactory not available");
|
var loggerFactory = services?.GetRequiredService<ILoggerFactory>() ?? throw new InvalidOperationException("LoggerFactory not available");
|
||||||
|
|
@ -133,8 +130,6 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
{
|
{
|
||||||
Completion = new() { Values = ["test"] }
|
Completion = new() { Values = ["test"] }
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddSingleton<ILoggerProvider>(_mockLoggerProvider);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -144,7 +139,7 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
|
|
||||||
await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var logMessage = Assert.Single(_mockLoggerProvider.LogMessages, m => m.Message == "ListResourceTemplatesFilter executed");
|
var logMessage = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "ListResourceTemplatesFilter executed");
|
||||||
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
||||||
Assert.Equal("ListResourceTemplatesFilter", logMessage.Category);
|
Assert.Equal("ListResourceTemplatesFilter", logMessage.Category);
|
||||||
}
|
}
|
||||||
|
|
@ -156,7 +151,7 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
|
|
||||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var logMessage = Assert.Single(_mockLoggerProvider.LogMessages, m => m.Message == "ListToolsFilter executed");
|
var logMessage = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "ListToolsFilter executed");
|
||||||
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
||||||
Assert.Equal("ListToolsFilter", logMessage.Category);
|
Assert.Equal("ListToolsFilter", logMessage.Category);
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +163,7 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
|
|
||||||
await client.CallToolAsync("test_tool_method", cancellationToken: TestContext.Current.CancellationToken);
|
await client.CallToolAsync("test_tool_method", cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var logMessage = Assert.Single(_mockLoggerProvider.LogMessages, m => m.Message == "CallToolFilter executed for tool: test_tool_method");
|
var logMessage = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "CallToolFilter executed for tool: test_tool_method");
|
||||||
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
||||||
Assert.Equal("CallToolFilter", logMessage.Category);
|
Assert.Equal("CallToolFilter", logMessage.Category);
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +189,7 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
|
|
||||||
await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var logMessage = Assert.Single(_mockLoggerProvider.LogMessages, m => m.Message == "ListPromptsFilter executed");
|
var logMessage = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "ListPromptsFilter executed");
|
||||||
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
||||||
Assert.Equal("ListPromptsFilter", logMessage.Category);
|
Assert.Equal("ListPromptsFilter", logMessage.Category);
|
||||||
}
|
}
|
||||||
|
|
@ -206,7 +201,7 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
|
|
||||||
await client.GetPromptAsync("test_prompt_method", cancellationToken: TestContext.Current.CancellationToken);
|
await client.GetPromptAsync("test_prompt_method", cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var logMessage = Assert.Single(_mockLoggerProvider.LogMessages, m => m.Message == "GetPromptFilter executed for prompt: test_prompt_method");
|
var logMessage = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "GetPromptFilter executed for prompt: test_prompt_method");
|
||||||
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
||||||
Assert.Equal("GetPromptFilter", logMessage.Category);
|
Assert.Equal("GetPromptFilter", logMessage.Category);
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +213,7 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
|
|
||||||
await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var logMessage = Assert.Single(_mockLoggerProvider.LogMessages, m => m.Message == "ListResourcesFilter executed");
|
var logMessage = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "ListResourcesFilter executed");
|
||||||
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
||||||
Assert.Equal("ListResourcesFilter", logMessage.Category);
|
Assert.Equal("ListResourcesFilter", logMessage.Category);
|
||||||
}
|
}
|
||||||
|
|
@ -230,7 +225,7 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
|
|
||||||
await client.ReadResourceAsync("test://resource/123", cancellationToken: TestContext.Current.CancellationToken);
|
await client.ReadResourceAsync("test://resource/123", cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var logMessage = Assert.Single(_mockLoggerProvider.LogMessages, m => m.Message == "ReadResourceFilter executed for resource: test://resource/{id}");
|
var logMessage = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "ReadResourceFilter executed for resource: test://resource/{id}");
|
||||||
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
||||||
Assert.Equal("ReadResourceFilter", logMessage.Category);
|
Assert.Equal("ReadResourceFilter", logMessage.Category);
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +238,7 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
var reference = new PromptReference { Name = "test_prompt_method" };
|
var reference = new PromptReference { Name = "test_prompt_method" };
|
||||||
await client.CompleteAsync(reference, "argument", "value", cancellationToken: TestContext.Current.CancellationToken);
|
await client.CompleteAsync(reference, "argument", "value", cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var logMessage = Assert.Single(_mockLoggerProvider.LogMessages, m => m.Message == "CompleteFilter executed");
|
var logMessage = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "CompleteFilter executed");
|
||||||
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
||||||
Assert.Equal("CompleteFilter", logMessage.Category);
|
Assert.Equal("CompleteFilter", logMessage.Category);
|
||||||
}
|
}
|
||||||
|
|
@ -255,7 +250,7 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
|
|
||||||
await client.SubscribeToResourceAsync("test://resource/123", cancellationToken: TestContext.Current.CancellationToken);
|
await client.SubscribeToResourceAsync("test://resource/123", cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var logMessage = Assert.Single(_mockLoggerProvider.LogMessages, m => m.Message == "SubscribeToResourcesFilter executed");
|
var logMessage = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "SubscribeToResourcesFilter executed");
|
||||||
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
||||||
Assert.Equal("SubscribeToResourcesFilter", logMessage.Category);
|
Assert.Equal("SubscribeToResourcesFilter", logMessage.Category);
|
||||||
}
|
}
|
||||||
|
|
@ -267,7 +262,7 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
|
|
||||||
await client.UnsubscribeFromResourceAsync("test://resource/123", cancellationToken: TestContext.Current.CancellationToken);
|
await client.UnsubscribeFromResourceAsync("test://resource/123", cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var logMessage = Assert.Single(_mockLoggerProvider.LogMessages, m => m.Message == "UnsubscribeFromResourcesFilter executed");
|
var logMessage = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "UnsubscribeFromResourcesFilter executed");
|
||||||
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
||||||
Assert.Equal("UnsubscribeFromResourcesFilter", logMessage.Category);
|
Assert.Equal("UnsubscribeFromResourcesFilter", logMessage.Category);
|
||||||
}
|
}
|
||||||
|
|
@ -279,7 +274,7 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
|
|
||||||
await client.SetLoggingLevelAsync(LoggingLevel.Info, cancellationToken: TestContext.Current.CancellationToken);
|
await client.SetLoggingLevelAsync(LoggingLevel.Info, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var logMessage = Assert.Single(_mockLoggerProvider.LogMessages, m => m.Message == "SetLoggingLevelFilter executed");
|
var logMessage = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "SetLoggingLevelFilter executed");
|
||||||
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
Assert.Equal(LogLevel.Information, logMessage.LogLevel);
|
||||||
Assert.Equal("SetLoggingLevelFilter", logMessage.Category);
|
Assert.Equal("SetLoggingLevelFilter", logMessage.Category);
|
||||||
}
|
}
|
||||||
|
|
@ -291,7 +286,7 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
|
||||||
|
|
||||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var logMessages = _mockLoggerProvider.LogMessages
|
var logMessages = MockLoggerProvider.LogMessages
|
||||||
.Where(m => m.Category.StartsWith("ListToolsOrder"))
|
.Where(m => m.Category.StartsWith("ListToolsOrder"))
|
||||||
.Select(m => m.Message);
|
.Select(m => m.Message);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ using Microsoft.Extensions.Options;
|
||||||
using ModelContextProtocol.Client;
|
using ModelContextProtocol.Client;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using ModelContextProtocol.Tests.Utils;
|
|
||||||
using Moq;
|
using Moq;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
|
@ -26,8 +25,6 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
private MockLoggerProvider _mockLoggerProvider = new();
|
|
||||||
|
|
||||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||||
{
|
{
|
||||||
mcpServerBuilder
|
mcpServerBuilder
|
||||||
|
|
@ -113,7 +110,6 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
||||||
.WithTools<EchoTool>(serializerOptions: BuilderToolsJsonContext.Default.Options);
|
.WithTools<EchoTool>(serializerOptions: BuilderToolsJsonContext.Default.Options);
|
||||||
|
|
||||||
services.AddSingleton(new ObjectWithId());
|
services.AddSingleton(new ObjectWithId());
|
||||||
services.AddSingleton<ILoggerProvider>(_mockLoggerProvider);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -378,7 +374,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
||||||
Assert.NotEmpty(result.Content);
|
Assert.NotEmpty(result.Content);
|
||||||
Assert.Contains("An error occurred", (result.Content[0] as TextContentBlock)?.Text);
|
Assert.Contains("An error occurred", (result.Content[0] as TextContentBlock)?.Text);
|
||||||
|
|
||||||
var errorLog = Assert.Single(_mockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Error);
|
var errorLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Error);
|
||||||
Assert.Equal($"\"throw_exception\" threw an unhandled exception.", errorLog.Message);
|
Assert.Equal($"\"throw_exception\" threw an unhandled exception.", errorLog.Message);
|
||||||
Assert.IsType<InvalidOperationException>(errorLog.Exception);
|
Assert.IsType<InvalidOperationException>(errorLog.Exception);
|
||||||
Assert.Equal("Test error", errorLog.Exception.Message);
|
Assert.Equal("Test error", errorLog.Exception.Message);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue