diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
index 662e436eb..1d05da0d0 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
@@ -774,15 +774,19 @@ private async Task PerformDynamicClientRegistrationAsync(
}
///
- /// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC.
- /// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server.
+ /// Verifies that the resource URI in the metadata matches the original request URL.
+ /// Accepts either an exact match with the full request URL, or a match with the base URL
+ /// (authority only, path discarded) as allowed by the MCP spec, which derives the authorization
+ /// base URL by discarding the path component from the MCP server URL.
///
/// The metadata to verify.
///
/// The original URL the client used to make the request to the resource server or the root Uri for the resource server
/// if the metadata was automatically requested from the root well-known location.
///
- /// True if the resource URI exactly matches the original request URL, otherwise false.
+ ///
+ /// True if the resource URI exactly matches the original request URL or its authority-level base URL, otherwise false.
+ ///
private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri resourceLocation)
{
if (protectedResourceMetadata.Resource is null)
@@ -790,14 +794,22 @@ private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResou
return false;
}
- // Per RFC: The resource value must be identical to the URL that the client used
- // to make the request to the resource server. Compare entire URIs, not just the host.
-
// Normalize the URIs to ensure consistent comparison
string normalizedMetadataResource = NormalizeUri(protectedResourceMetadata.Resource);
string normalizedResourceLocation = NormalizeUri(resourceLocation);
- return string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase);
+ // Accept exact match with the full MCP endpoint URI
+ if (string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ // Per the MCP spec's "Canonical Server URI" section, both the path-specific URI (e.g. https://mcp.example.com/mcp)
+ // and the authority-only URI (e.g. https://mcp.example.com) are valid canonical URIs for identifying an MCP server.
+ // Accept a match with the base URL (authority only, path discarded) to support servers that use the less specific form.
+
+ string normalizedBaseUrl = NormalizeUri(new Uri(resourceLocation.GetLeftPart(UriPartial.Authority)));
+ return string.Equals(normalizedMetadataResource, normalizedBaseUrl, StringComparison.OrdinalIgnoreCase);
}
///
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
index 1ec6fddc6..84c25e38c 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
@@ -768,7 +768,7 @@ public async Task CannotAuthenticate_WhenResourceMetadataResourceIsNonRootParent
//
// https://datatracker.ietf.org/doc/html/rfc9728/#section-3.3
//
- // CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates we won't fall back to root in this case.
+ // CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates that a root-level resource is accepted in this case.
// CanAuthenticate_WithResourceMetadataPathFallbacks validates we will fall back to root when resource_metadata is missing.
Builder.Services.Configure(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);
Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options =>
@@ -807,8 +807,14 @@ await McpClient.CreateAsync(
Assert.Contains("does not match", ex.Message);
}
+ ///
+ /// Verifies that OAuth authentication succeeds when the protected resource metadata URI
+ /// matches the root server URL, even when the actual MCP endpoint is at a subpath.
+ /// This tests the flexible URI matching behavior where the resource URI can be less specific
+ /// than the actual endpoint being accessed.
+ ///
[Fact]
- public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath()
+ public async Task CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath()
{
const string requestedResourcePath = "/mcp/tools";
@@ -839,12 +845,99 @@ public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPa
},
}, HttpClient, LoggerFactory);
- var ex = await Assert.ThrowsAsync(async () =>
+ await using var client = await McpClient.CreateAsync(
+ transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
+ }
+
+ ///
+ /// Verifies that OAuth authentication fails when the protected resource metadata URI
+ /// does not match the requested MCP server endpoint. This ensures that clients cannot
+ /// use OAuth tokens intended for one server to access a different server.
+ ///
+ [Fact]
+ public async Task CannotAuthenticate_WhenResourceMetadataUriDoesNotMatch()
+ {
+ const string requestedResourcePath = "/mcp/tools";
+ const string differentResourceUri = "http://different-server.example.com";
+
+ Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
- await McpClient.CreateAsync(
- transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
+ options.ResourceMetadata = new ProtectedResourceMetadata
+ {
+ Resource = differentResourceUri,
+ AuthorizationServers = { OAuthServerUrl },
+ };
});
+ await using var app = Builder.Build();
+
+ app.MapMcp(requestedResourcePath).RequireAuthorization();
+
+ await app.StartAsync(TestContext.Current.CancellationToken);
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new Uri($"{McpServerUrl}{requestedResourcePath}"),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ },
+ }, HttpClient, LoggerFactory);
+
+ // This should fail because the resource URI doesn't match
+ var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync(
+ transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains("does not match", ex.Message);
+ }
+
+ ///
+ /// Verifies that OAuth authentication fails when the protected resource metadata URI is an
+ /// unrelated path on the same host as the requested endpoint (e.g. resource=.../service-a vs
+ /// endpoint .../service-b). This ensures the authority-level fallback only accepts an exact match
+ /// or an authority-only resource, and not arbitrary sibling paths on the same host.
+ ///
+ [Fact]
+ public async Task CannotAuthenticate_WhenResourceMetadataResourceIsDifferentPathOnSameAuthority()
+ {
+ const string requestedResourcePath = "/service-b";
+ const string differentResourcePath = "/service-a";
+
+ Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options =>
+ {
+ options.ResourceMetadata = new ProtectedResourceMetadata
+ {
+ Resource = $"{McpServerUrl}{differentResourcePath}",
+ AuthorizationServers = { OAuthServerUrl },
+ };
+ });
+
+ await using var app = Builder.Build();
+
+ app.MapMcp(requestedResourcePath).RequireAuthorization();
+
+ await app.StartAsync(TestContext.Current.CancellationToken);
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new Uri($"{McpServerUrl}{requestedResourcePath}"),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ },
+ }, HttpClient, LoggerFactory);
+
+ // This should fail because the resource URI is a different path on the same host,
+ // which is neither an exact match nor the authority-only base URL.
+ var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync(
+ transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
+
Assert.Contains("does not match", ex.Message);
}
@@ -853,7 +946,7 @@ public async Task ResourceMetadata_DoesNotAddTrailingSlash()
{
// This test verifies that automatically derived resource URIs don't have trailing slashes
// and that the client doesn't add them during authentication
-
+
// Don't explicitly set Resource - let it be derived from the request
await using var app = await StartMcpServerAsync();
@@ -993,10 +1086,10 @@ public async Task ResourceMetadata_PreservesExplicitTrailingSlash()
{
// This test verifies that explicitly configured trailing slashes are preserved
const string resourceWithTrailingSlash = "http://localhost:5000/";
-
+
// Configure ValidResources to accept the trailing slash version for this test
TestOAuthServer.ValidResources = [resourceWithTrailingSlash, "http://localhost:5000/mcp"];
-
+
Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata