csharp-sdk/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
Copilot dbf91bcb1a
Change ProtectedResourceMetadata URI properties to strings and build resource strings directly (#1264)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: halter73 <54385+halter73@users.noreply.github.com>
Co-authored-by: Stephen Halter <halter73@gmail.com>
2026-02-12 22:28:43 -05:00

856 lines
34 KiB
C#

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using ModelContextProtocol.Server;
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using Xunit.Sdk;
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
public class AuthTests : OAuthTestBase
{
private const string ClientMetadataDocumentUrl = $"{OAuthServerUrl}/client-metadata/cimd-client.json";
public AuthTests(ITestOutputHelper outputHelper)
: base(outputHelper)
{
}
[Fact]
public async Task CanAuthenticate()
{
await using var app = await StartMcpServerAsync();
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 CannotAuthenticate_WithoutOAuthConfiguration()
{
await using var app = await StartMcpServerAsync();
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
}, HttpClient, LoggerFactory);
var httpEx = await Assert.ThrowsAsync<HttpRequestException>(async () => await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal(HttpStatusCode.Unauthorized, httpEx.StatusCode);
}
[Fact]
public async Task CannotAuthenticate_WithUnregisteredClient()
{
await using var app = await StartMcpServerAsync();
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "unregistered-demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
// The EqualException is thrown by HandleAuthorizationUrlAsync when the /authorize request gets a 400
var equalEx = await Assert.ThrowsAsync<EqualException>(async () => await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
public async Task CanAuthenticate_WithDynamicClientRegistration()
{
await using var app = await StartMcpServerAsync();
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client",
ClientUri = new Uri("https://example.com"),
},
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
[Fact]
public async Task CanAuthenticate_WithClientMetadataDocument()
{
await using var app = await StartMcpServerAsync();
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl)
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
[Fact]
public async Task UsesDynamicClientRegistration_WhenCimdNotSupported()
{
// Disable CIMD support on the test OAuth server so the client
// falls back to dynamic registration even if a CIMD URL is provided.
TestOAuthServer.ClientIdMetadataDocumentSupported = false;
await using var app = await StartMcpServerAsync();
// Provide an invalid CIMD URL; if CIMD were used, auth would fail.
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client (No CIMD)",
ClientUri = new Uri("https://example.com/no-cimd"),
},
},
}, HttpClient, LoggerFactory);
// Should succeed via dynamic client registration.
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
[Fact]
public async Task DoesNotUseClientMetadataDocument_WhenClientIdIsSpecified()
{
await using var app = await StartMcpServerAsync();
// Provide an invalid CIMD URL; if CIMD were used, auth would fail.
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new ClientOAuthOptions()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
[Theory]
[InlineData("http://localhost:7029/client-metadata/cimd-client.json")] // Non-HTTPS Scheme
[InlineData("http://localhost:7029")] // Missing path
public async Task CannotAuthenticate_WithInvalidClientMetadataDocument(string uri)
{
await using var app = await StartMcpServerAsync();
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri(uri),
},
}, HttpClient, LoggerFactory);
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
Assert.StartsWith("Failed to handle unauthorized response", ex.Message);
}
[Fact]
public async Task CanAuthenticate_WithTokenRefresh()
{
var hasForcedRefresh = false;
Builder.Services.AddHttpContextAccessor();
Builder.Services.AddMcpServer(options =>
{
options.ToolCollection = new();
})
.AddListToolsFilter(next =>
{
return async (mcpContext, cancellationToken) =>
{
if (!hasForcedRefresh)
{
hasForcedRefresh = true;
var httpContext = mcpContext.Services!.GetRequiredService<IHttpContextAccessor>().HttpContext!;
await httpContext.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme);
await httpContext.Response.CompleteAsync();
throw new Exception("This exception will not impact the client because the response has already been completed.");
}
else
{
return await next(mcpContext, cancellationToken);
}
};
});
await using var app = await StartMcpServerAsync();
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);
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.True(TestOAuthServer.HasRefreshedToken);
}
[Fact]
public async Task CanAuthenticate_WithExtraParams()
{
await using var app = await StartMcpServerAsync();
Uri? lastAuthorizationUri = null;
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 = (uri, redirect, ct) =>
{
lastAuthorizationUri = uri;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
},
AdditionalAuthorizationParameters = new Dictionary<string, string>
{
["custom_param"] = "custom_value",
}
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(lastAuthorizationUri?.Query);
Assert.Contains("custom_param=custom_value", lastAuthorizationUri?.Query);
}
[Fact]
public async Task CannotOverrideExistingParameters_WithExtraParams()
{
await using var app = await StartMcpServerAsync();
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,
AdditionalAuthorizationParameters = new Dictionary<string, string>
{
["redirect_uri"] = "custom_value",
}
},
}, HttpClient, LoggerFactory);
await Assert.ThrowsAsync<ArgumentException>(() => McpClient.CreateAsync(
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 AuthorizationFlow_UsesScopeFromProtectedResourceMetadata()
{
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"];
});
await using var app = await StartMcpServerAsync();
string? requestedScope = null;
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 = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
},
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("mcp:tools files:read", requestedScope);
}
[Fact]
public async Task AuthorizationFlow_UsesScopeFromChallengeHeader()
{
var challengeScopes = "challenge:read challenge:write";
await using var app = Builder.Build();
app.Use(next =>
{
return async context =>
{
await next(context);
if (context.Response.StatusCode != 401)
{
return;
}
context.Response.Headers.WWWAuthenticate = $"Bearer resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{challengeScopes}\"";
};
});
app.UseAuthentication();
app.UseAuthorization();
app.MapMcp().RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
string? requestedScope = null;
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 = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
},
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(challengeScopes, requestedScope);
}
[Fact]
public async Task AuthorizationFlow_UsesScopeFromForbiddenHeader()
{
var adminScopes = "admin:read admin:write";
Builder.Services.AddHttpContextAccessor();
Builder.Services.AddMcpServer()
.WithTools([
McpServerTool.Create([McpServerTool(Name = "admin-tool")]
async (IServiceProvider serviceProvider, ClaimsPrincipal user) =>
{
if (!user.HasClaim("scope", adminScopes))
{
var httpContext = serviceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext!;
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
httpContext.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{adminScopes}\"";
await httpContext.Response.CompleteAsync();
throw new Exception("This exception will not impact the client because the response has already been completed.");
}
return "Admin tool executed.";
}),
]);
string? requestedScope = null;
await using var app = await StartMcpServerAsync();
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 = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
},
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("mcp:tools", requestedScope);
var adminResult = await client.CallToolAsync("admin-tool", cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("Admin tool executed.", adminResult.Content[0].ToString());
Assert.Equal(adminScopes, requestedScope);
}
[Fact]
public async Task AuthorizationFails_WhenResourceMetadataPortDiffers()
{
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata!.Resource = "http://localhost:5999";
});
await using var app = await StartMcpServerAsync();
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 Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
public async Task CanAuthenticate_WithAuthorizationServerPathInsertionMetadata()
{
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata!.AuthorizationServers = [$"{OAuthServerUrl}/tenant1"];
});
await using var app = await StartMcpServerAsync();
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);
var requests = TestOAuthServer.MetadataRequests.ToArray();
Assert.Contains("/.well-known/oauth-authorization-server/tenant1", requests);
}
[Fact]
public async Task CanAuthenticate_WithAuthorizationServerPathFallbacks()
{
const string issuerPath = "/subdir/tenant2";
TestOAuthServer.DisabledMetadataPaths.Add($"/.well-known/oauth-authorization-server{issuerPath}");
TestOAuthServer.DisabledMetadataPaths.Add($"/.well-known/openid-configuration{issuerPath}");
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata!.AuthorizationServers = [$"{OAuthServerUrl}{issuerPath}"];
});
await using var app = await StartMcpServerAsync();
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);
Assert.Equal(
[
$"/.well-known/oauth-authorization-server{issuerPath}",
$"/.well-known/openid-configuration{issuerPath}",
$"{issuerPath}/.well-known/openid-configuration",
"/.well-known/openid-configuration",
],
TestOAuthServer.MetadataRequests);
}
[Fact]
public async Task CanAuthenticate_WithResourceMetadataPathFallbacks()
{
const string resourcePath = "/mcp";
List<string> wellKnownRequests = [];
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);
await using var app = Builder.Build();
var metadata = new ProtectedResourceMetadata
{
Resource = $"{McpServerUrl}{resourcePath}",
AuthorizationServers = { OAuthServerUrl },
};
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-protected-resource", out var remaining))
{
wellKnownRequests.Add(context.Request.Path);
if (remaining.HasValue)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
}
await next();
});
app.UseAuthentication();
app.UseAuthorization();
app.MapMcp(resourcePath).RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
var endpoint = new Uri(new Uri(McpServerUrl), resourcePath);
await using var transport = new HttpClientTransport(new()
{
Endpoint = endpoint,
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);
Assert.Equal(
[
$"/.well-known/oauth-protected-resource{resourcePath}",
"/.well-known/oauth-protected-resource"
],
wellKnownRequests);
}
[Fact]
public async Task CannotAuthenticate_WhenResourceMetadataResourceIsNonRootParentPath()
{
const string configuredResourcePath = "/mcp";
const string requestedResourcePath = "/mcp/tools";
// Remove resource_metadata from the WWW-Authenticate header, because we should only fall back at all (even to root) when it's missing.
//
// If the protected resource metadata was retrieved from a URL returned by the protected resource via the WWW-Authenticate resource_metadata parameter,
// then the resource value returned MUST be identical to the URL that the client used to make the request to the resource server.
// If these values are not identical, the data contained in the response MUST NOT be used.
//
// https://datatracker.ietf.org/doc/html/rfc9728/#section-3.3
//
// CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates we won't fall back to root in this case.
// CanAuthenticate_WithResourceMetadataPathFallbacks validates we will fall back to root when resource_metadata is missing.
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = $"{McpServerUrl}{configuredResourcePath}",
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);
var ex = await Assert.ThrowsAsync<McpException>(async () =>
{
await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
});
Assert.Contains("does not match", ex.Message);
}
[Fact]
public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath()
{
const string requestedResourcePath = "/mcp/tools";
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = McpServerUrl,
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);
var ex = await Assert.ThrowsAsync<McpException>(async () =>
{
await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
});
Assert.Contains("does not match", ex.Message);
}
[Fact]
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();
// First, manually check the PRM document doesn't contain a trailing slash
using var metadataResponse = await HttpClient.GetAsync(
"/.well-known/oauth-protected-resource",
TestContext.Current.CancellationToken
);
Assert.Equal(HttpStatusCode.OK, metadataResponse.StatusCode);
var metadata = await metadataResponse.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
McpJsonUtilities.DefaultOptions,
TestContext.Current.CancellationToken
);
Assert.NotNull(metadata);
Assert.Equal("http://localhost:5000", metadata.Resource);
Assert.DoesNotMatch(@"/$", metadata.Resource); // No trailing slash
// Then authenticate with the client - this will use the derived resource URI
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);
// This should succeed - the client should not add a trailing slash
// If the client incorrectly added a trailing slash, ValidResources would reject it
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
[Fact]
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<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = resourceWithTrailingSlash,
AuthorizationServers = { OAuthServerUrl },
ScopesSupported = ["mcp:tools"],
};
});
await using var app = await StartMcpServerAsync();
// First, manually check the PRM document contains the trailing slash
using var metadataResponse = await HttpClient.GetAsync(
"/.well-known/oauth-protected-resource",
TestContext.Current.CancellationToken
);
Assert.Equal(HttpStatusCode.OK, metadataResponse.StatusCode);
var metadata = await metadataResponse.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
McpJsonUtilities.DefaultOptions,
TestContext.Current.CancellationToken
);
Assert.NotNull(metadata);
Assert.Equal(resourceWithTrailingSlash, metadata.Resource);
Assert.Matches(@"/$", metadata.Resource); // Has trailing slash
// Then authenticate with the client
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);
// This should succeed with the explicitly configured trailing slash
// If the client incorrectly trimmed the slash, ValidResources would reject it
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
}