Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions samples/ProtectedMcpClient/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -77,15 +77,17 @@ 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
```
- 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

Expand Down
8 changes: 7 additions & 1 deletion samples/ProtectedMcpServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>() ?? ["http://localhost:5173"];

// This sample runs the MCP server on localhost:7071, and it is intended to be callable from a
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 26 additions & 5 deletions samples/ProtectedMcpServer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
{
}
}
}
}
54 changes: 48 additions & 6 deletions tests/ModelContextProtocol.TestOAuthServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/// <summary>The command line switch that hosts the standalone server over HTTPS.</summary>
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
Expand Down Expand Up @@ -42,14 +46,30 @@ public sealed class Program
/// </summary>
/// <param name="loggerProvider">Optional logger provider for logging.</param>
/// <param name="kestrelTransport">Optional Kestrel transport for in-memory connections.</param>
public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactory? kestrelTransport = null)
/// <param name="useHttps">
/// Whether to serve over HTTPS using the ASP.NET Core developer certificate. When <see langword="false"/>,
/// the server listens over plain HTTP on loopback and its metadata advertises <c>http</c> endpoints.
/// Tests keep the default of <see langword="true"/>; <see cref="Main"/> defaults to <see langword="false"/>
/// so the samples work with clients that don't trust the developer certificate.
/// </param>
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";
}

/// <summary>
/// Gets a value indicating whether the server is hosted over HTTPS using the ASP.NET Core
/// developer certificate, in which case its metadata advertises <c>https</c> endpoints.
/// </summary>
public bool UseHttps { get; }

/// <summary>
/// Gets a task that completes when the server has started and is ready to accept connections.
/// </summary>
Expand Down Expand Up @@ -150,9 +170,28 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
/// <summary>
/// Entry point for the application.
/// </summary>
/// <param name="args">Command line arguments.</param>
/// <param name="args">Command line arguments. Pass <c>--https</c> to serve over HTTPS instead of plain HTTP.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public static Task Main(string[] args) => new Program().RunServerAsync(args);
/// <remarks>
/// 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.
/// </remarks>
public static Task Main(string[] args) =>
new Program(useHttps: ShouldUseHttps(args)).RunServerAsync(WithoutHttpsSwitch(args));

/// <summary>
/// Gets whether <paramref name="args"/> asks for HTTPS hosting. Standalone runs default to plain HTTP.
/// </summary>
public static bool ShouldUseHttps(string[] args) => args.Contains(HttpsSwitch, StringComparer.OrdinalIgnoreCase);

/// <summary>
/// Strips <see cref="HttpsSwitch"/>, which the host's command line configuration provider rejects
/// because it carries no value.
/// </summary>
public static string[] WithoutHttpsSwitch(string[] args) =>
args.Where(arg => !string.Equals(arg, HttpsSwitch, StringComparison.OrdinalIgnoreCase)).ToArray();

/// <summary>
/// Runs the OAuth server with the specified parameters.
Expand All @@ -179,7 +218,10 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
{
kestrelOptions.ListenLocalhost(_port, listenOptions =>
{
listenOptions.UseHttps();
if (UseHttps)
{
listenOptions.UseHttps();
}
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand Down