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
6 changes: 4 additions & 2 deletions samples/ProtectedMcpClient/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 12 additions & 1 deletion samples/ProtectedMcpServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 +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,
Expand Down
39 changes: 34 additions & 5 deletions samples/ProtectedMcpServer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
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;

// 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)
{
}
}
}
}
61 changes: 55 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 plain HTTP.</summary>
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
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,35 @@ 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>--http</c> to serve over plain HTTP instead of HTTPS.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public static Task Main(string[] args) => new Program().RunServerAsync(args);
/// <remarks>
/// 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.
/// <para>
/// <c>--http</c> 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.
/// </para>
/// </remarks>
public static Task Main(string[] args) =>
new Program(useHttps: ShouldUseHttps(args)).RunServerAsync(WithoutHttpSwitch(args));

/// <summary>
/// Gets whether the standalone server should host over HTTPS. Defaults to <see langword="true"/>;
/// <see cref="HttpSwitch"/> opts out.
/// </summary>
public static bool ShouldUseHttps(string[] args) => !args.Contains(HttpSwitch, StringComparer.OrdinalIgnoreCase);

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

/// <summary>
/// Runs the OAuth server with the specified parameters.
Expand All @@ -179,7 +225,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
Expand Up @@ -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"
}
}
}
}