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",