From 0d834e205b598b64e34f2698e3d6cee9ed1d5c27 Mon Sep 17 00:00:00 2001 From: Charles Cheng Date: Sat, 8 Aug 2026 16:33:53 +0800 Subject: [PATCH 1/2] 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", From 58488714b4647cd329cb4957d5d4c088726257a6 Mon Sep 17 00:00:00 2001 From: Charles Cheng Date: Wed, 26 Aug 2026 12:08:24 +0800 Subject: [PATCH 2/2] Keep HTTPS as the default and make plain HTTP an explicit opt-in The MCP authorization security requirements and RFC 8414 both require authorization server endpoints to be served over HTTPS, and the localhost carve-out covers redirect URIs rather than the authorization server itself. Defaulting the fixture to plain HTTP therefore demonstrated a configuration that works but does not conform, in a sample the README presents as the normal editor-integration setup. Invert the switch: TestOAuthServer hosts over HTTPS unless --http is passed, and the launch profiles follow. ProtectedMcpServer reads its authority from OAuth:ServerUrl with an HTTPS default, and derives RequireHttpsMetadata from that scheme, so the relaxation only applies when the sample has deliberately been pointed at a loopback HTTP authority. The READMEs document the --http pair of commands for clients that cannot fetch metadata from the ASP.NET Core developer certificate. --- samples/ProtectedMcpClient/README.md | 4 +-- samples/ProtectedMcpServer/Program.cs | 19 ++++++---- samples/ProtectedMcpServer/README.md | 36 +++++++++++-------- .../OAuth/TestOAuthServerHostingTests.cs | 20 +++++------ .../Program.cs | 33 ++++++++++------- .../Properties/launchSettings.json | 10 +++--- 6 files changed, 71 insertions(+), 51 deletions(-) diff --git a/samples/ProtectedMcpClient/README.md b/samples/ProtectedMcpClient/README.md index 4ae1aeb44..d87cc7f52 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 `http://localhost:7029` +The OAuth server will start at `https://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**: `http://localhost:7029` +- **OAuth Server**: `https://localhost:7029` - **Protected Resource**: `http://localhost:7071` ## Available Tools diff --git a/samples/ProtectedMcpServer/Program.cs b/samples/ProtectedMcpServer/Program.cs index 17209af15..f1d470995 100644 --- a/samples/ProtectedMcpServer/Program.cs +++ b/samples/ProtectedMcpServer/Program.cs @@ -9,10 +9,12 @@ var builder = WebApplication.CreateBuilder(args); var serverUrl = "http://localhost:7071/"; -// 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"; +// 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 @@ -43,9 +45,12 @@ { // 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; + // 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 1f14bb13c..26b889b04 100644 --- a/samples/ProtectedMcpServer/README.md +++ b/samples/ProtectedMcpServer/README.md @@ -27,10 +27,18 @@ cd tests\ModelContextProtocol.TestOAuthServer dotnet run --framework net9.0 ``` -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. +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 @@ -59,11 +67,11 @@ gets a 401 with `WWW-Authenticate`, reads the protected resource metadata, disco 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. +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 @@ -89,15 +97,15 @@ 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 `http://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 -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. +`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 diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TestOAuthServerHostingTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TestOAuthServerHostingTests.cs index 61aebef9f..16deb45b7 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TestOAuthServerHostingTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TestOAuthServerHostingTests.cs @@ -3,10 +3,10 @@ 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. +// 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) @@ -17,16 +17,16 @@ public TestOAuthServerHostingTests(ITestOutputHelper outputHelper) } [Fact] - public void StandaloneServer_UsesPlainHttp_UnlessHttpsIsRequested() + public void StandaloneServer_UsesHttps_UnlessPlainHttpIsRequested() { - 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"])); + 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.WithoutHttpsSwitch(["--https", "--urls", "http://localhost:7029"])); + TestOAuthServer.Program.WithoutHttpSwitch(["--http", "--urls", "http://localhost:7029"])); } [Theory] diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 897e372aa..e56b19309 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -13,8 +13,8 @@ public sealed class Program { private const int _port = 7029; - /// The command line switch that hosts the standalone server over HTTPS. - public const string HttpsSwitch = "--https"; + /// 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; @@ -170,28 +170,35 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// /// Entry point for the application. /// - /// Command line arguments. Pass --https to serve over HTTPS instead of plain HTTP. + /// Command line arguments. Pass --http to serve over plain HTTP instead of HTTPS. /// A task representing the asynchronous operation. /// - /// 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. + /// 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(WithoutHttpsSwitch(args)); + new Program(useHttps: ShouldUseHttps(args)).RunServerAsync(WithoutHttpSwitch(args)); /// - /// Gets whether asks for HTTPS hosting. Standalone runs default to plain HTTP. + /// Gets whether the standalone server should host over HTTPS. Defaults to ; + /// opts out. /// - public static bool ShouldUseHttps(string[] args) => args.Contains(HttpsSwitch, StringComparer.OrdinalIgnoreCase); + public static bool ShouldUseHttps(string[] args) => !args.Contains(HttpSwitch, StringComparer.OrdinalIgnoreCase); /// - /// Strips , which the host's command line configuration provider rejects + /// 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(); + public static string[] WithoutHttpSwitch(string[] args) => + args.Where(arg => !string.Equals(arg, HttpSwitch, StringComparison.OrdinalIgnoreCase)).ToArray(); /// /// Runs the OAuth server with the specified parameters. diff --git a/tests/ModelContextProtocol.TestOAuthServer/Properties/launchSettings.json b/tests/ModelContextProtocol.TestOAuthServer/Properties/launchSettings.json index 9077bfd5e..a835c3ed7 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Properties/launchSettings.json +++ b/tests/ModelContextProtocol.TestOAuthServer/Properties/launchSettings.json @@ -1,21 +1,21 @@ { "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { - "http": { + "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "http://localhost:7029", + "applicationUrl": "https://localhost:7029", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, - "https": { + "http": { "commandName": "Project", - "commandLineArgs": "--https", + "commandLineArgs": "--http", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "https://localhost:7029", + "applicationUrl": "http://localhost:7029", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }