From 0d834e205b598b64e34f2698e3d6cee9ed1d5c27 Mon Sep 17 00:00:00 2001 From: Charles Cheng Date: Sat, 8 Aug 2026 16:33:53 +0800 Subject: [PATCH] Serve the sample authorization server over loopback HTTP The ProtectedMcpServer sample pairs with TestOAuthServer, which hosts the authorization server on the ASP.NET Core developer certificate. Clients that keep their own CA list rather than using the OS trust store cannot fetch https://localhost:7029/.well-known/oauth-authorization-server from it. VS Code is one: the fetch fails, it treats that as a server without metadata, and falls back to the pre-2025-06-18 defaults derived from the MCP server URL. That drops the registration endpoint, so it asks for a client id, and then sends the browser to http://localhost:7071/authorize, which 404s. Host the standalone server over plain HTTP on loopback so its metadata is reachable without trusting anything first, and keep the developer certificate available behind --https. Tests construct Program directly and are unaffected. --- samples/ProtectedMcpClient/README.md | 10 ++- samples/ProtectedMcpServer/Program.cs | 8 +- samples/ProtectedMcpServer/README.md | 31 ++++++-- .../OAuth/TestOAuthServerHostingTests.cs | 78 +++++++++++++++++++ .../Program.cs | 54 +++++++++++-- .../Properties/launchSettings.json | 10 +++ 6 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TestOAuthServerHostingTests.cs diff --git a/samples/ProtectedMcpClient/README.md b/samples/ProtectedMcpClient/README.md index 81ae67cee..4ae1aeb44 100644 --- a/samples/ProtectedMcpClient/README.md +++ b/samples/ProtectedMcpClient/README.md @@ -27,7 +27,7 @@ cd tests\ModelContextProtocol.TestOAuthServer dotnet run --framework net9.0 ``` -The OAuth server will start at `https://localhost:7029` +The OAuth server will start at `http://localhost:7029` ### Step 2: Start the Protected MCP Server @@ -66,7 +66,7 @@ The client is configured with: - **Client ID**: `demo-client` - **Client Secret**: `demo-secret` - **Redirect URI**: `http://localhost:1179/callback` -- **OAuth Server**: `https://localhost:7029` +- **OAuth Server**: `http://localhost:7029` - **Protected Resource**: `http://localhost:7071` ## Available Tools @@ -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..17209af15 100644 --- a/samples/ProtectedMcpServer/Program.cs +++ b/samples/ProtectedMcpServer/Program.cs @@ -9,7 +9,10 @@ var builder = WebApplication.CreateBuilder(args); var serverUrl = "http://localhost:7071/"; -var inMemoryOAuthServerUrl = "https://localhost:7029"; +// The bundled TestOAuthServer listens on loopback over plain HTTP so that MCP clients which don't +// trust the ASP.NET Core developer certificate (VS Code, for one) can fetch its metadata. A real +// deployment uses an HTTPS authorization server. +var inMemoryOAuthServerUrl = "http://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 +43,9 @@ { // Configure to validate tokens from our in-memory OAuth server options.Authority = inMemoryOAuthServerUrl; + // Only because that authority is an HTTP loopback address. Leave this at its default of true + // in production so the OpenID Connect metadata and signing keys are fetched over HTTPS. + options.RequireHttpsMetadata = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, diff --git a/samples/ProtectedMcpServer/README.md b/samples/ProtectedMcpServer/README.md index ecbfee633..1f14bb13c 100644 --- a/samples/ProtectedMcpServer/README.md +++ b/samples/ProtectedMcpServer/README.md @@ -27,7 +27,10 @@ cd tests\ModelContextProtocol.TestOAuthServer dotnet run --framework net9.0 ``` -The OAuth server will start at `https://localhost:7029` +The OAuth server will start at `http://localhost:7029`. It listens over plain HTTP on loopback so +that any MCP client can fetch its metadata without first trusting a certificate. To host it on the +ASP.NET Core developer certificate instead, run `dotnet run --framework net9.0 -- --https` and +update `inMemoryOAuthServerUrl` in this sample's `Program.cs` to match. ### Step 2: Start the Protected MCP Server @@ -49,6 +52,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. + +If you host the authorization server over HTTPS with `--https`, the client has to trust the ASP.NET +Core developer certificate to get that far. VS Code doesn't use the operating system trust store for +these requests, so the metadata fetch fails, 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. + ## What the Server Provides ### Protected Resources @@ -73,11 +89,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 `http://localhost:7029` - Validate token audience as `demo-client` - Require tokens to have appropriate scopes (`mcp:tools`) - Provide OAuth resource metadata for client discovery +Because that authority is an HTTP loopback address, the sample sets +`JwtBearerOptions.RequireHttpsMetadata = false`. Never do that against an authority you don't 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 +111,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 +128,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..61aebef9f --- /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; + +// The samples run TestOAuthServer standalone over plain HTTP so that clients which don't trust the +// ASP.NET Core developer certificate can still fetch its metadata. Whichever scheme it's hosted 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_UsesPlainHttp_UnlessHttpsIsRequested() + { + Assert.False(TestOAuthServer.Program.ShouldUseHttps([])); + Assert.False(TestOAuthServer.Program.ShouldUseHttps(["--urls", "http://localhost:7029"])); + Assert.True(TestOAuthServer.Program.ShouldUseHttps(["--https"])); + Assert.True(TestOAuthServer.Program.ShouldUseHttps(["--HTTPS"])); + + // 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.WithoutHttpsSwitch(["--https", "--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..897e372aa 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 HTTPS. + public const string HttpsSwitch = "--https"; + + 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,28 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// /// Entry point for the application. /// - /// Command line arguments. + /// Command line arguments. Pass --https to serve over HTTPS instead of plain HTTP. /// A task representing the asynchronous operation. - public static Task Main(string[] args) => new Program().RunServerAsync(args); + /// + /// The samples run this server standalone and connect to it from clients such as VS Code, whose HTTP + /// stack carries its own CA list and therefore rejects the ASP.NET Core developer certificate. Those + /// clients treat a failed metadata fetch as "no metadata" and silently fall back to guessing OAuth + /// endpoints on the MCP server itself, so loopback is served over plain HTTP by default. + /// + public static Task Main(string[] args) => + new Program(useHttps: ShouldUseHttps(args)).RunServerAsync(WithoutHttpsSwitch(args)); + + /// + /// Gets whether asks for HTTPS hosting. Standalone runs default to plain HTTP. + /// + public static bool ShouldUseHttps(string[] args) => args.Contains(HttpsSwitch, StringComparer.OrdinalIgnoreCase); + + /// + /// Strips , which the host's command line configuration provider rejects + /// because it carries no value. + /// + public static string[] WithoutHttpsSwitch(string[] args) => + args.Where(arg => !string.Equals(arg, HttpsSwitch, StringComparison.OrdinalIgnoreCase)).ToArray(); /// /// Runs the OAuth server with the specified parameters. @@ -179,7 +218,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..9077bfd5e 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Properties/launchSettings.json +++ b/tests/ModelContextProtocol.TestOAuthServer/Properties/launchSettings.json @@ -1,8 +1,18 @@ { "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:7029", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, "https": { "commandName": "Project", + "commandLineArgs": "--https", "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:7029",