Authorization Support (Using ASP.NET Core Native AuthN/AuthZ Integration) (#377)
This commit is contained in:
parent
cd8e5d5f7f
commit
cdf88e7f5d
6 changed files with 4207 additions and 126 deletions
93
samples/ProtectedMCPServer/Program.cs
Normal file
93
samples/ProtectedMCPServer/Program.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using ModelContextProtocol.AspNetCore.Authentication;
|
||||
using ProtectedMCPServer.Tools;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var serverUrl = "http://localhost:7071/";
|
||||
var inMemoryOAuthServerUrl = "https://localhost:7029";
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
// Configure to validate tokens from our in-memory OAuth server
|
||||
options.Authority = inMemoryOAuthServerUrl;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidAudience = serverUrl, // Validate that the audience matches the resource metadata as suggested in RFC 8707
|
||||
ValidIssuer = inMemoryOAuthServerUrl,
|
||||
NameClaimType = "name",
|
||||
RoleClaimType = "roles"
|
||||
};
|
||||
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnTokenValidated = context =>
|
||||
{
|
||||
var name = context.Principal?.Identity?.Name ?? "unknown";
|
||||
var email = context.Principal?.FindFirstValue("preferred_username") ?? "unknown";
|
||||
Console.WriteLine($"Token validated for: {name} ({email})");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnAuthenticationFailed = context =>
|
||||
{
|
||||
Console.WriteLine($"Authentication failed: {context.Exception.Message}");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnChallenge = context =>
|
||||
{
|
||||
Console.WriteLine($"Challenging client to authenticate with Entra ID");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
})
|
||||
.AddMcp(options =>
|
||||
{
|
||||
options.ResourceMetadata = new()
|
||||
{
|
||||
Resource = new Uri(serverUrl),
|
||||
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
|
||||
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
|
||||
ScopesSupported = ["mcp:tools"],
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddMcpServer()
|
||||
.WithTools<WeatherTools>()
|
||||
.WithHttpTransport();
|
||||
|
||||
// Configure HttpClientFactory for weather.gov API
|
||||
builder.Services.AddHttpClient("WeatherApi", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.weather.gov");
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0"));
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Use the default MCP policy name that we've configured
|
||||
app.MapMcp().RequireAuthorization();
|
||||
|
||||
Console.WriteLine($"Starting MCP server with authorization at {serverUrl}");
|
||||
Console.WriteLine($"Using in-memory OAuth server at {inMemoryOAuthServerUrl}");
|
||||
Console.WriteLine($"Protected Resource Metadata URL: {serverUrl}.well-known/oauth-protected-resource");
|
||||
Console.WriteLine("Press Ctrl+C to stop the server");
|
||||
|
||||
app.Run(serverUrl);
|
||||
12
samples/ProtectedMCPServer/Properties/launchSettings.json
Normal file
12
samples/ProtectedMCPServer/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"profiles": {
|
||||
"ProtectedMCPServer": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "http://localhost:7071"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
samples/ProtectedMCPServer/ProtectedMCPServer.csproj
Normal file
15
samples/ProtectedMCPServer/ProtectedMCPServer.csproj
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>783daef3-9c45-408d-a1d3-7caf44724f39</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
125
samples/ProtectedMCPServer/README.md
Normal file
125
samples/ProtectedMCPServer/README.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Protected MCP Server Sample
|
||||
|
||||
This sample demonstrates how to create an MCP server that requires OAuth 2.0 authentication to access its tools and resources. The server provides weather-related tools protected by JWT bearer token authentication.
|
||||
|
||||
## Overview
|
||||
|
||||
The Protected MCP Server sample shows how to:
|
||||
- Create an MCP server with OAuth 2.0 protection
|
||||
- Configure JWT bearer token authentication
|
||||
- Implement protected MCP tools and resources
|
||||
- Integrate with ASP.NET Core authentication and authorization
|
||||
- Provide OAuth resource metadata for client discovery
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 9.0 or later
|
||||
- A running TestOAuthServer (for OAuth authentication)
|
||||
|
||||
## Setup and Running
|
||||
|
||||
### Step 1: Start the Test OAuth Server
|
||||
|
||||
First, you need to start the TestOAuthServer which issues access tokens:
|
||||
|
||||
```bash
|
||||
cd tests\ModelContextProtocol.TestOAuthServer
|
||||
dotnet run --framework net9.0
|
||||
```
|
||||
|
||||
The OAuth server will start at `https://localhost:7029`
|
||||
|
||||
### Step 2: Start the Protected MCP Server
|
||||
|
||||
Run this protected server:
|
||||
|
||||
```bash
|
||||
cd samples\ProtectedMCPServer
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The protected server will start at `http://localhost:7071`
|
||||
|
||||
### Step 3: Test with Protected MCP Client
|
||||
|
||||
You can test the server using the ProtectedMCPClient sample:
|
||||
|
||||
```bash
|
||||
cd samples\ProtectedMCPClient
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## What the Server Provides
|
||||
|
||||
### Protected Resources
|
||||
|
||||
- **MCP Endpoint**: `http://localhost:7071/` (requires authentication)
|
||||
- **OAuth Resource Metadata**: `http://localhost:7071/.well-known/oauth-protected-resource`
|
||||
|
||||
### Available Tools
|
||||
|
||||
The server provides weather-related tools that require authentication:
|
||||
|
||||
1. **GetAlerts**: Get weather alerts for a US state
|
||||
- Parameter: `state` (string) - 2-letter US state abbreviation
|
||||
- Example: `GetAlerts` with `state: "WA"`
|
||||
|
||||
2. **GetForecast**: Get weather forecast for a location
|
||||
- Parameters:
|
||||
- `latitude` (double) - Latitude coordinate
|
||||
- `longitude` (double) - Longitude coordinate
|
||||
- Example: `GetForecast` with `latitude: 47.6062, longitude: -122.3321`
|
||||
|
||||
### Authentication Configuration
|
||||
|
||||
The server is configured to:
|
||||
- Accept JWT bearer tokens from the OAuth server at `https://localhost:7029`
|
||||
- Validate token audience as `demo-client`
|
||||
- Require tokens to have appropriate scopes (`mcp:tools`)
|
||||
- Provide OAuth resource metadata for client discovery
|
||||
|
||||
## Architecture
|
||||
|
||||
The server uses:
|
||||
- **ASP.NET Core** for hosting and HTTP handling
|
||||
- **JWT Bearer Authentication** for token validation
|
||||
- **MCP Authentication Extensions** for OAuth resource metadata
|
||||
- **HttpClient** for calling the weather.gov API
|
||||
- **Authorization** to protect MCP endpoints
|
||||
|
||||
## Configuration Details
|
||||
|
||||
- **Server URL**: `http://localhost:7071`
|
||||
- **OAuth Server**: `https://localhost:7029`
|
||||
- **Demo Client ID**: `demo-client`
|
||||
|
||||
## Testing Without Client
|
||||
|
||||
You can test the server directly using HTTP tools:
|
||||
|
||||
1. Get an access token from the OAuth server
|
||||
2. Include the token in the `Authorization: Bearer <token>` header
|
||||
3. Make requests to the MCP endpoints
|
||||
|
||||
## External Dependencies
|
||||
|
||||
The weather tools use the National Weather Service API at `api.weather.gov` to fetch real weather data.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- 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`
|
||||
- Check console output for authentication events and errors
|
||||
|
||||
## Key Files
|
||||
|
||||
- `Program.cs`: Server setup with authentication and MCP configuration
|
||||
- `Tools/WeatherTools.cs`: Weather tool implementations
|
||||
- `Tools/HttpClientExt.cs`: HTTP client extensions
|
||||
- `Properties/launchSettings.json`: Development launch configuration
|
||||
13
samples/ProtectedMCPServer/Tools/HttpClientExt.cs
Normal file
13
samples/ProtectedMCPServer/Tools/HttpClientExt.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol;
|
||||
|
||||
internal static class HttpClientExt
|
||||
{
|
||||
public static async Task<JsonDocument> ReadJsonDocumentAsync(this HttpClient client, string requestUri)
|
||||
{
|
||||
using var response = await client.GetAsync(requestUri);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
}
|
||||
}
|
||||
67
samples/ProtectedMCPServer/Tools/WeatherTools.cs
Normal file
67
samples/ProtectedMCPServer/Tools/WeatherTools.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ProtectedMCPServer.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class WeatherTools
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public WeatherTools(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Get weather alerts for a US state.")]
|
||||
public async Task<string> GetAlerts(
|
||||
[Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("WeatherApi");
|
||||
using var jsonDocument = await client.ReadJsonDocumentAsync($"/alerts/active/area/{state}");
|
||||
var jsonElement = jsonDocument.RootElement;
|
||||
var alerts = jsonElement.GetProperty("features").EnumerateArray();
|
||||
|
||||
if (!alerts.Any())
|
||||
{
|
||||
return "No active alerts for this state.";
|
||||
}
|
||||
|
||||
return string.Join("\n--\n", alerts.Select(alert =>
|
||||
{
|
||||
JsonElement properties = alert.GetProperty("properties");
|
||||
return $"""
|
||||
Event: {properties.GetProperty("event").GetString()}
|
||||
Area: {properties.GetProperty("areaDesc").GetString()}
|
||||
Severity: {properties.GetProperty("severity").GetString()}
|
||||
Description: {properties.GetProperty("description").GetString()}
|
||||
Instruction: {properties.GetProperty("instruction").GetString()}
|
||||
""";
|
||||
}));
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Get weather forecast for a location.")]
|
||||
public async Task<string> GetForecast(
|
||||
[Description("Latitude of the location.")] double latitude,
|
||||
[Description("Longitude of the location.")] double longitude)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("WeatherApi");
|
||||
var pointUrl = string.Create(CultureInfo.InvariantCulture, $"/points/{latitude},{longitude}");
|
||||
using var jsonDocument = await client.ReadJsonDocumentAsync(pointUrl);
|
||||
var forecastUrl = jsonDocument.RootElement.GetProperty("properties").GetProperty("forecast").GetString()
|
||||
?? throw new Exception($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}");
|
||||
|
||||
using var forecastDocument = await client.ReadJsonDocumentAsync(forecastUrl);
|
||||
var periods = forecastDocument.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray();
|
||||
|
||||
return string.Join("\n---\n", periods.Select(period => $"""
|
||||
{period.GetProperty("name").GetString()}
|
||||
Temperature: {period.GetProperty("temperature").GetInt32()}°F
|
||||
Wind: {period.GetProperty("windSpeed").GetString()} {period.GetProperty("windDirection").GetString()}
|
||||
Forecast: {period.GetProperty("detailedForecast").GetString()}
|
||||
"""));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue