diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index 46b8e898..66d64667 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -30,12 +30,21 @@ public async Task 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) + string expectedMetadataPath; + if (Options.ResourceMetadataUri == null) + { + expectedMetadataPath = string.Empty; + } + else if (!Options.ResourceMetadataUri.IsAbsoluteUri) { // For relative URIs, it's just the path component. expectedMetadataPath = Options.ResourceMetadataUri.OriginalString; } + else + { + // For absolute URIs, extract the path component + expectedMetadataPath = Options.ResourceMetadataUri.AbsolutePath; + } // If the path doesn't match, let the request continue through the pipeline if (!string.Equals(requestPath, expectedMetadataPath, StringComparison.OrdinalIgnoreCase)) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs index fff7d6d4..1504d86f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs @@ -8,6 +8,7 @@ using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using System.Net; +using System.Net.Http.Json; using System.Reflection; using Xunit.Sdk; @@ -391,6 +392,84 @@ public void CloneResourceMetadataClonesAllProperties() Assert.Empty(propertyNames); } + [Fact] + public async Task ResourceMetadataEndpoint_ResolvesCorrectly_WithAbsoluteUriIncludingPathComponent() + { + const string enterprisePath = "/enterprise"; + const string metadataPath = "/.well-known/oauth-protected-resource"; + const string fullMetadataPath = enterprisePath + metadataPath; + + // Configure the builder with a fresh authentication setup for this test + var testBuilder = WebApplication.CreateSlimBuilder(); + testBuilder.Services.AddSingleton(XunitLoggerProvider); + + testBuilder.Services.AddAuthentication(options => + { + options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.Backchannel = HttpClient; + options.Authority = OAuthServerUrl; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidAudience = McpServerUrl, + ValidIssuer = OAuthServerUrl, + NameClaimType = "name", + RoleClaimType = "roles" + }; + }) + .AddMcp(options => + { + // Set an absolute URI with a path component after the host + options.ResourceMetadataUri = new Uri($"{McpServerUrl}{fullMetadataPath}"); + options.ResourceMetadata = new ProtectedResourceMetadata + { + Resource = new Uri(McpServerUrl), + AuthorizationServers = { new Uri(OAuthServerUrl) }, + ScopesSupported = ["mcp:tools"] + }; + }); + + testBuilder.Services.AddAuthorization(); + testBuilder.Services.AddMcpServer().WithHttpTransport(); + + await using var app = testBuilder.Build(); + + app.MapMcp().RequireAuthorization(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + // Test that the metadata endpoint responds at the correct path with the path component + var metadataResponse = await HttpClient.GetAsync($"{McpServerUrl}{fullMetadataPath}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, metadataResponse.StatusCode); + + var metadata = await metadataResponse.Content.ReadFromJsonAsync( + McpJsonUtilities.DefaultOptions, TestContext.Current.CancellationToken); + + Assert.NotNull(metadata); + Assert.Equal(new Uri(McpServerUrl), metadata.Resource); + Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers); + Assert.Contains("mcp:tools", metadata.ScopesSupported); + + // Test that a request without the path component returns 401 (not the metadata) + var unauthorizedResponse = await HttpClient.GetAsync($"{McpServerUrl}/message", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedResponse.StatusCode); + + // Verify the WWW-Authenticate header includes the full absolute URI with path component + var wwwAuthHeader = unauthorizedResponse.Headers.WwwAuthenticate.FirstOrDefault(); + Assert.NotNull(wwwAuthHeader); + Assert.Equal("Bearer", wwwAuthHeader.Scheme); + Assert.Contains($"resource_metadata=\"{McpServerUrl}{fullMetadataPath}\"", wwwAuthHeader.Parameter); + + await app.StopAsync(TestContext.Current.CancellationToken); + } + private async Task HandleAuthorizationUrlAsync(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken) { _lastAuthorizationUri = authorizationUri;