diff --git a/samples/ProtectedMcpClient/README.md b/samples/ProtectedMcpClient/README.md index 81ae67cee..d87cc7f52 100644 --- a/samples/ProtectedMcpClient/README.md +++ b/samples/ProtectedMcpClient/README.md @@ -77,7 +77,10 @@ Once authenticated, the client can access weather tools including: ## Troubleshooting -- Ensure the ASP.NET Core dev certificate is trusted. +- The TestOAuthServer listens over plain HTTP on loopback. If you host it over HTTPS instead + (`dotnet run --framework net9.0 -- --https`, which also needs a matching `inMemoryOAuthServerUrl` + in the ProtectedMcpServer sample), ensure the ASP.NET Core dev certificate is trusted and allow it + in your browser as well. ``` dotnet dev-certs https --clean dotnet dev-certs https --trust @@ -85,7 +88,6 @@ Once authenticated, the client can access weather tools including: - Ensure all three services are running in the correct order - Check that ports 7029, 7071, and 1179 are available - If the browser doesn't open automatically, copy the authorization URL from the console and open it manually -- Make sure to allow the OAuth server's self-signed certificate in your browser ## Key Files diff --git a/samples/ProtectedMcpServer/Program.cs b/samples/ProtectedMcpServer/Program.cs index f539e73bb..f1d470995 100644 --- a/samples/ProtectedMcpServer/Program.cs +++ b/samples/ProtectedMcpServer/Program.cs @@ -9,7 +9,12 @@ var builder = WebApplication.CreateBuilder(args); var serverUrl = "http://localhost:7071/"; -var inMemoryOAuthServerUrl = "https://localhost:7029"; +// The bundled TestOAuthServer hosts over HTTPS by default, which is what the MCP authorization +// security requirements and RFC 8414 ask for. Clients whose HTTP stack does not use the operating +// system trust store (VS Code, for one) cannot fetch metadata from the developer certificate; for +// those, start the authorization server with `--http` and point this sample at it by setting +// `OAuth:ServerUrl` (for example `dotnet run -- --OAuth:ServerUrl=http://localhost:7029`). +var inMemoryOAuthServerUrl = builder.Configuration["OAuth:ServerUrl"] ?? "https://localhost:7029"; var allowedOrigins = builder.Configuration.GetSection("Mcp:AllowedOrigins").Get() ?? ["http://localhost:5173"]; // This sample runs the MCP server on localhost:7071, and it is intended to be callable from a @@ -40,6 +45,12 @@ { // Configure to validate tokens from our in-memory OAuth server options.Authority = inMemoryOAuthServerUrl; + // Stays at its default of true for the HTTPS authority above. It only relaxes when the sample has + // been pointed at a plain-HTTP loopback authority on purpose, because metadata and signing keys + // would otherwise be fetched over an unprotected connection. Never relax it for an authority you + // do not fully control on the local machine. + options.RequireHttpsMetadata = + inMemoryOAuthServerUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase); options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, diff --git a/samples/ProtectedMcpServer/README.md b/samples/ProtectedMcpServer/README.md index ecbfee633..26b889b04 100644 --- a/samples/ProtectedMcpServer/README.md +++ b/samples/ProtectedMcpServer/README.md @@ -27,7 +27,18 @@ cd tests\ModelContextProtocol.TestOAuthServer dotnet run --framework net9.0 ``` -The OAuth server will start at `https://localhost:7029` +The OAuth server will start at `https://localhost:7029`, on the ASP.NET Core developer certificate. +Run `dotnet dev-certs https --trust` once if you have not already. + +If your MCP client cannot fetch the metadata from that certificate - see [Step 4](#step-4-test-with-an-editor) - +start the authorization server over plain loopback HTTP instead and point this sample at it: + +```bash +# terminal 1 +dotnet run --framework net9.0 -- --http +# terminal 2 +dotnet run --OAuth:ServerUrl=http://localhost:7029 +``` ### Step 2: Start the Protected MCP Server @@ -49,6 +60,19 @@ cd samples\ProtectedMcpClient dotnet run ``` +### Step 4: Test with an editor + +Add `http://localhost:7071/` as an HTTP MCP server in VS Code (or any other MCP client). The client +gets a 401 with `WWW-Authenticate`, reads the protected resource metadata, discovers the +authorization server at `http://localhost:7029`, registers itself through Dynamic Client +Registration, and completes the code flow in the browser. + +VS Code does not use the operating system trust store for these requests, so with the default HTTPS +authorization server the metadata fetch fails even after `dotnet dev-certs https --trust`, and the +fallback for pre-2025-06-18 servers kicks in: it asks for a client ID because it no longer knows +about the registration endpoint, then sends the browser to `http://localhost:7071/authorize`, which +404s. Use the `--http` pair of commands from Step 1 for those clients. + ## What the Server Provides ### Protected Resources @@ -73,11 +97,16 @@ The server provides weather-related tools that require authentication: ### Authentication Configuration The server is configured to: -- Accept JWT bearer tokens from the OAuth server at `https://localhost:7029` +- Accept JWT bearer tokens from the OAuth server at `https://localhost:7029`, overridable with `OAuth:ServerUrl` - Validate token audience as `demo-client` - Require tokens to have appropriate scopes (`mcp:tools`) - Provide OAuth resource metadata for client discovery +`JwtBearerOptions.RequireHttpsMetadata` follows the scheme of that authority, so it stays at its +default of `true` unless you have deliberately pointed the sample at a plain-HTTP loopback address. +Never relax it for an authority you do not fully control on the local machine: it lets the OpenID +Connect metadata and the token signing keys be fetched over an unprotected connection. + ## Architecture The server uses: @@ -90,7 +119,7 @@ The server uses: ## Configuration Details - **Server URL**: `http://localhost:7071` -- **OAuth Server**: `https://localhost:7029` +- **OAuth Server**: `http://localhost:7029` - **Demo Client ID**: `demo-client` ## Testing Without Client @@ -107,14 +136,14 @@ The weather tools use the National Weather Service API at `api.weather.gov` to f ## Troubleshooting -- Ensure the ASP.NET Core dev certificate is trusted. +- If you run the TestOAuthServer with `--https`, ensure the ASP.NET Core dev certificate is trusted. ``` dotnet dev-certs https --clean dotnet dev-certs https --trust ``` - Ensure the TestOAuthServer is running first - Check that port 7071 is available -- Verify the OAuth server is accessible at `https://localhost:7029` +- Verify the OAuth server is accessible at `http://localhost:7029` - Check console output for authentication events and errors ## Key Files diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TestOAuthServerHostingTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TestOAuthServerHostingTests.cs new file mode 100644 index 000000000..16deb45b7 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TestOAuthServerHostingTests.cs @@ -0,0 +1,78 @@ +using ModelContextProtocol.AspNetCore.Tests.Utils; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests.OAuth; + +// TestOAuthServer hosts over HTTPS by default, as the MCP authorization security requirements and +// RFC 8414 ask for; `--http` opts into plain loopback HTTP for clients that don't trust the ASP.NET +// Core developer certificate. Whichever scheme it ends up on, the discovery document has to describe +// that same origin, otherwise clients follow endpoints they can't reach and fall back to guessing. +public class TestOAuthServerHostingTests : KestrelInMemoryTest +{ + public TestOAuthServerHostingTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + // The dev cert may not be installed on CI, so don't validate it when hosting over HTTPS. + SocketsHttpHandler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true; + } + + [Fact] + public void StandaloneServer_UsesHttps_UnlessPlainHttpIsRequested() + { + Assert.True(TestOAuthServer.Program.ShouldUseHttps([])); + Assert.True(TestOAuthServer.Program.ShouldUseHttps(["--urls", "https://localhost:7029"])); + Assert.False(TestOAuthServer.Program.ShouldUseHttps(["--http"])); + Assert.False(TestOAuthServer.Program.ShouldUseHttps(["--HTTP"])); + + // The switch carries no value, so it has to be gone before the host parses the rest. + Assert.Equal(["--urls", "http://localhost:7029"], + TestOAuthServer.Program.WithoutHttpSwitch(["--http", "--urls", "http://localhost:7029"])); + } + + [Theory] + [InlineData(true, "https://localhost:7029")] + [InlineData(false, "http://localhost:7029")] + public async Task DiscoveryDocument_AdvertisesEndpointsOnTheHostedOrigin(bool useHttps, string expectedIssuer) + { + using var testCts = new CancellationTokenSource(); + var oauthServer = new TestOAuthServer.Program(XunitLoggerProvider, KestrelInMemoryTransport, useHttps); + var runTask = oauthServer.RunServerAsync(cancellationToken: testCts.Token); + + try + { + await oauthServer.ServerStarted.WaitAsync(TestContext.Current.CancellationToken); + + using var response = await HttpClient.GetAsync( + $"{expectedIssuer}/.well-known/oauth-authorization-server", + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + + using var metadata = JsonDocument.Parse( + await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(expectedIssuer, metadata.RootElement.GetProperty("issuer").GetString()); + + foreach (var property in metadata.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind is not JsonValueKind.String || + (!property.Name.EndsWith("_endpoint", StringComparison.Ordinal) && property.Name != "jwks_uri")) + { + continue; + } + + Assert.StartsWith($"{expectedIssuer}/", property.Value.GetString()); + } + } + finally + { + testCts.Cancel(); + try + { + await runTask; + } + catch (OperationCanceledException) + { + } + } + } +} diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 73663dc94..e56b19309 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -12,8 +12,12 @@ namespace ModelContextProtocol.TestOAuthServer; public sealed class Program { private const int _port = 7029; - private static readonly string _url = $"https://localhost:{_port}"; - private static readonly string _clientMetadataDocumentUrl = $"{_url}/client-metadata/cimd-client.json"; + + /// The command line switch that hosts the standalone server over plain HTTP. + public const string HttpSwitch = "--http"; + + private readonly string _url; + private readonly string _clientMetadataDocumentUrl; // Port 5000 is used by tests and port 7071 is used by the ProtectedMcpServer sample // Per MCP spec, URIs should not have trailing slashes unless semantically significant @@ -42,14 +46,30 @@ public sealed class Program /// /// Optional logger provider for logging. /// Optional Kestrel transport for in-memory connections. - public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactory? kestrelTransport = null) + /// + /// Whether to serve over HTTPS using the ASP.NET Core developer certificate. When , + /// the server listens over plain HTTP on loopback and its metadata advertises http endpoints. + /// Tests keep the default of ; defaults to + /// so the samples work with clients that don't trust the developer certificate. + /// + public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactory? kestrelTransport = null, bool useHttps = true) { _rsa = RSA.Create(2048); _keyId = Guid.NewGuid().ToString(); _loggerProvider = loggerProvider; _kestrelTransport = kestrelTransport; + UseHttps = useHttps; + _url = $"{(useHttps ? "https" : "http")}://localhost:{_port}"; + // Advertised over HTTP too, though clients that follow the CIMD draft require an HTTPS client id. + _clientMetadataDocumentUrl = $"{_url}/client-metadata/cimd-client.json"; } + /// + /// Gets a value indicating whether the server is hosted over HTTPS using the ASP.NET Core + /// developer certificate, in which case its metadata advertises https endpoints. + /// + public bool UseHttps { get; } + /// /// Gets a task that completes when the server has started and is ready to accept connections. /// @@ -150,9 +170,35 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// /// Entry point for the application. /// - /// Command line arguments. + /// Command line arguments. Pass --http to serve over plain HTTP instead of HTTPS. /// A task representing the asynchronous operation. - public static Task Main(string[] args) => new Program().RunServerAsync(args); + /// + /// HTTPS is the default because the MCP authorization security requirements and RFC 8414 both require + /// authorization server endpoints to be served over HTTPS; the localhost carve-out covers redirect URIs, + /// not the authorization server itself. + /// + /// --http exists for clients whose HTTP stack carries its own CA list and therefore rejects the + /// ASP.NET Core developer certificate - VS Code, for one. Such a client treats the failed metadata fetch + /// as "no metadata" and silently falls back to guessing OAuth endpoints on the MCP server itself. Serving + /// this fixture over loopback HTTP works around that, at the cost of a configuration that does not conform + /// to the requirements above, so it stays opt-in. + /// + /// + public static Task Main(string[] args) => + new Program(useHttps: ShouldUseHttps(args)).RunServerAsync(WithoutHttpSwitch(args)); + + /// + /// Gets whether the standalone server should host over HTTPS. Defaults to ; + /// opts out. + /// + public static bool ShouldUseHttps(string[] args) => !args.Contains(HttpSwitch, StringComparer.OrdinalIgnoreCase); + + /// + /// Strips , which the host's command line configuration provider rejects + /// because it carries no value. + /// + public static string[] WithoutHttpSwitch(string[] args) => + args.Where(arg => !string.Equals(arg, HttpSwitch, StringComparison.OrdinalIgnoreCase)).ToArray(); /// /// Runs the OAuth server with the specified parameters. @@ -179,7 +225,10 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel { kestrelOptions.ListenLocalhost(_port, listenOptions => { - listenOptions.UseHttps(); + if (UseHttps) + { + listenOptions.UseHttps(); + } }); }); diff --git a/tests/ModelContextProtocol.TestOAuthServer/Properties/launchSettings.json b/tests/ModelContextProtocol.TestOAuthServer/Properties/launchSettings.json index 71b2b21fe..a835c3ed7 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Properties/launchSettings.json +++ b/tests/ModelContextProtocol.TestOAuthServer/Properties/launchSettings.json @@ -9,6 +9,16 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + }, + "http": { + "commandName": "Project", + "commandLineArgs": "--http", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:7029", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } } } }