From b35b29aa287f1535cbb2a2c8db69a2f8b055ca2f Mon Sep 17 00:00:00 2001 From: Shahab Rashid Date: Mon, 3 Nov 2025 12:59:15 -0500 Subject: [PATCH 1/2] Enhance MCP authentication handler to correctly handle absolute URIs for resource metadata endpoint --- .../McpAuthenticationHandler.cs | 13 ++- .../AuthTests.cs | 79 +++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index 46b8e898b..66d646678 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 fff7d6d42..4e40f96be 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 = new WebApplicationBuilder(); + testBuilder.Services.AddLogging(loggingBuilder => loggingBuilder.AddProvider(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; From 822545b02abe9462dac7e358102c4c7ac99759d3 Mon Sep 17 00:00:00 2001 From: Shahab Rashid Date: Mon, 3 Nov 2025 14:10:17 -0500 Subject: [PATCH 2/2] Test fix --- tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs index 4e40f96be..1504d86fa 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs @@ -400,8 +400,8 @@ public async Task ResourceMetadataEndpoint_ResolvesCorrectly_WithAbsoluteUriIncl const string fullMetadataPath = enterprisePath + metadataPath; // Configure the builder with a fresh authentication setup for this test - var testBuilder = new WebApplicationBuilder(); - testBuilder.Services.AddLogging(loggingBuilder => loggingBuilder.AddProvider(XunitLoggerProvider)); + var testBuilder = WebApplication.CreateSlimBuilder(); + testBuilder.Services.AddSingleton(XunitLoggerProvider); testBuilder.Services.AddAuthentication(options => {