Authorization Support (Using ASP.NET Core Native AuthN/AuthZ Integration) (#377)
This commit is contained in:
parent
cd8e5d5f7f
commit
cdf88e7f5d
65 changed files with 4207 additions and 126 deletions
|
|
@ -19,6 +19,7 @@
|
|||
|
||||
<!-- Product dependencies LTS -->
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
|
||||
<PackageVersion Include="System.IO.Pipelines" Version="8.0.0" />
|
||||
|
|
@ -26,6 +27,8 @@
|
|||
|
||||
<!-- Product dependencies .NET 9 -->
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="$(System9Version)" />
|
||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(System9Version)" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(System9Version)" />
|
||||
<PackageVersion Include="System.IO.Pipelines" Version="$(System9Version)" />
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
<Project Path="samples/AspNetCoreSseServer/AspNetCoreSseServer.csproj" />
|
||||
<Project Path="samples/ChatWithTools/ChatWithTools.csproj" />
|
||||
<Project Path="samples/EverythingServer/EverythingServer.csproj" />
|
||||
<Project Path="samples/ProtectedMCPClient/ProtectedMCPClient.csproj" />
|
||||
<Project Path="samples/ProtectedMCPServer/ProtectedMCPServer.csproj" />
|
||||
<Project Path="samples/QuickstartClient/QuickstartClient.csproj" />
|
||||
<Project Path="samples/QuickstartWeatherServer/QuickstartWeatherServer.csproj" />
|
||||
<Project Path="samples/TestServerWithHosting/TestServerWithHosting.csproj" />
|
||||
|
|
@ -33,6 +35,7 @@
|
|||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj" />
|
||||
<Project Path="tests/ModelContextProtocol.TestOAuthServer/ModelContextProtocol.TestOAuthServer.csproj" />
|
||||
<Project Path="tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj" />
|
||||
<Project Path="tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj" />
|
||||
<Project Path="tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj" />
|
||||
|
|
|
|||
148
samples/ProtectedMCPClient/Program.cs
Normal file
148
samples/ProtectedMCPClient/Program.cs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Web;
|
||||
|
||||
var serverUrl = "http://localhost:7071/";
|
||||
|
||||
Console.WriteLine("Protected MCP Client");
|
||||
Console.WriteLine($"Connecting to weather server at {serverUrl}...");
|
||||
Console.WriteLine();
|
||||
|
||||
// We can customize a shared HttpClient with a custom handler if desired
|
||||
var sharedHandler = new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
var httpClient = new HttpClient(sharedHandler);
|
||||
|
||||
var consoleLoggerFactory = LoggerFactory.Create(builder =>
|
||||
{
|
||||
builder.AddConsole();
|
||||
});
|
||||
|
||||
var transport = new SseClientTransport(new()
|
||||
{
|
||||
Endpoint = new Uri(serverUrl),
|
||||
Name = "Secure Weather Client",
|
||||
OAuth = new()
|
||||
{
|
||||
ClientName = "ProtectedMcpClient",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
}
|
||||
}, httpClient, consoleLoggerFactory);
|
||||
|
||||
var client = await McpClientFactory.CreateAsync(transport, loggerFactory: consoleLoggerFactory);
|
||||
|
||||
var tools = await client.ListToolsAsync();
|
||||
if (tools.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No tools available on the server.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Found {tools.Count} tools on the server.");
|
||||
Console.WriteLine();
|
||||
|
||||
if (tools.Any(t => t.Name == "get_alerts"))
|
||||
{
|
||||
Console.WriteLine("Calling get_alerts tool...");
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
"get_alerts",
|
||||
new Dictionary<string, object?> { { "state", "WA" } }
|
||||
);
|
||||
|
||||
Console.WriteLine("Result: " + ((TextContentBlock)result.Content[0]).Text);
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
/// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser.
|
||||
/// This implementation demonstrates how SDK consumers can provide their own authorization flow.
|
||||
/// </summary>
|
||||
/// <param name="authorizationUrl">The authorization URL to open in the browser.</param>
|
||||
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The authorization code extracted from the callback, or null if the operation failed.</returns>
|
||||
static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
|
||||
{
|
||||
Console.WriteLine("Starting OAuth authorization flow...");
|
||||
Console.WriteLine($"Opening browser to: {authorizationUrl}");
|
||||
|
||||
var listenerPrefix = redirectUri.GetLeftPart(UriPartial.Authority);
|
||||
if (!listenerPrefix.EndsWith("/")) listenerPrefix += "/";
|
||||
|
||||
using var listener = new HttpListener();
|
||||
listener.Prefixes.Add(listenerPrefix);
|
||||
|
||||
try
|
||||
{
|
||||
listener.Start();
|
||||
Console.WriteLine($"Listening for OAuth callback on: {listenerPrefix}");
|
||||
|
||||
OpenBrowser(authorizationUrl);
|
||||
|
||||
var context = await listener.GetContextAsync();
|
||||
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
|
||||
var code = query["code"];
|
||||
var error = query["error"];
|
||||
|
||||
string responseHtml = "<html><body><h1>Authentication complete</h1><p>You can close this window now.</p></body></html>";
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(responseHtml);
|
||||
context.Response.ContentLength64 = buffer.Length;
|
||||
context.Response.ContentType = "text/html";
|
||||
context.Response.OutputStream.Write(buffer, 0, buffer.Length);
|
||||
context.Response.Close();
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
Console.WriteLine($"Auth error: {error}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(code))
|
||||
{
|
||||
Console.WriteLine("No authorization code received");
|
||||
return null;
|
||||
}
|
||||
|
||||
Console.WriteLine("Authorization code received successfully.");
|
||||
return code;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error getting auth code: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (listener.IsListening) listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the specified URL in the default browser.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to open.</param>
|
||||
static void OpenBrowser(Uri url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = url.ToString(),
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(psi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error opening browser. {ex.Message}");
|
||||
Console.WriteLine($"Please manually open this URL: {url}");
|
||||
}
|
||||
}
|
||||
18
samples/ProtectedMCPClient/ProtectedMCPClient.csproj
Normal file
18
samples/ProtectedMCPClient/ProtectedMCPClient.csproj
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
93
samples/ProtectedMCPClient/README.md
Normal file
93
samples/ProtectedMCPClient/README.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Protected MCP Client Sample
|
||||
|
||||
This sample demonstrates how to create an MCP client that connects to a protected MCP server using OAuth 2.0 authentication. The client implements a custom OAuth authorization flow with browser-based authentication.
|
||||
|
||||
## Overview
|
||||
|
||||
The Protected MCP Client sample shows how to:
|
||||
- Connect to an OAuth-protected MCP server
|
||||
- Handle OAuth 2.0 authorization code flow
|
||||
- Use custom authorization redirect handling
|
||||
- Call protected MCP tools with authentication
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 9.0 or later
|
||||
- A running TestOAuthServer (for OAuth authentication)
|
||||
- A running ProtectedMCPServer (for MCP services)
|
||||
|
||||
## Setup and Running
|
||||
|
||||
### Step 1: Start the Test OAuth Server
|
||||
|
||||
First, you need to start the TestOAuthServer which provides OAuth authentication:
|
||||
|
||||
```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
|
||||
|
||||
Next, start the ProtectedMCPServer which provides the weather tools:
|
||||
|
||||
```bash
|
||||
cd samples\ProtectedMCPServer
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The protected server will start at `http://localhost:7071`
|
||||
|
||||
### Step 3: Run the Protected MCP Client
|
||||
|
||||
Finally, run this client:
|
||||
|
||||
```bash
|
||||
cd samples\ProtectedMCPClient
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## What Happens
|
||||
|
||||
1. The client attempts to connect to the protected MCP server at `http://localhost:7071`
|
||||
2. The server responds with OAuth metadata indicating authentication is required
|
||||
3. The client initiates OAuth 2.0 authorization code flow:
|
||||
- Opens a browser to the authorization URL at the OAuth server
|
||||
- Starts a local HTTP listener on `http://localhost:1179/callback` to receive the authorization code
|
||||
- Exchanges the authorization code for an access token
|
||||
4. The client uses the access token to authenticate with the MCP server
|
||||
5. The client lists available tools and calls the `GetAlerts` tool for Washington state
|
||||
|
||||
## OAuth Configuration
|
||||
|
||||
The client is configured with:
|
||||
- **Client ID**: `demo-client`
|
||||
- **Client Secret**: `demo-secret`
|
||||
- **Redirect URI**: `http://localhost:1179/callback`
|
||||
- **OAuth Server**: `https://localhost:7029`
|
||||
- **Protected Resource**: `http://localhost:7071`
|
||||
|
||||
## Available Tools
|
||||
|
||||
Once authenticated, the client can access weather tools including:
|
||||
- **GetAlerts**: Get weather alerts for a US state
|
||||
- **GetForecast**: Get weather forecast for a location (latitude/longitude)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Ensure the ASP.NET Core dev certificate is trusted.
|
||||
```
|
||||
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
|
||||
|
||||
- `Program.cs`: Main client application with OAuth flow implementation
|
||||
- `ProtectedMCPClient.csproj`: Project file with dependencies
|
||||
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()}
|
||||
"""));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
namespace ModelContextProtocol.AspNetCore.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Default values used by MCP authentication.
|
||||
/// </summary>
|
||||
public static class McpAuthenticationDefaults
|
||||
{
|
||||
/// <summary>
|
||||
/// The default value used for authentication scheme name.
|
||||
/// </summary>
|
||||
public const string AuthenticationScheme = "McpAuth";
|
||||
|
||||
/// <summary>
|
||||
/// The default value used for authentication scheme display name.
|
||||
/// </summary>
|
||||
public const string DisplayName = "MCP Authentication";
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
namespace ModelContextProtocol.AspNetCore.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the authentication events for Model Context Protocol.
|
||||
/// </summary>
|
||||
public class McpAuthenticationEvents
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the function that is invoked when resource metadata is requested.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This function is called when a resource metadata request is made to the protected resource metadata endpoint.
|
||||
/// The implementer should set the <see cref="ResourceMetadataRequestContext.ResourceMetadata"/> property
|
||||
/// to provide the appropriate metadata for the current request.
|
||||
/// </remarks>
|
||||
public Func<ResourceMetadataRequestContext, Task> OnResourceMetadataRequest { get; set; } = context => Task.CompletedTask;
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using ModelContextProtocol.AspNetCore.Authentication;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for adding MCP authorization support to ASP.NET Core applications.
|
||||
/// </summary>
|
||||
public static class McpAuthenticationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds MCP authorization support to the application.
|
||||
/// </summary>
|
||||
/// <param name="builder">The authentication builder.</param>
|
||||
/// <param name="configureOptions">An action to configure MCP authentication options.</param>
|
||||
/// <returns>The authentication builder for chaining.</returns>
|
||||
public static AuthenticationBuilder AddMcp(
|
||||
this AuthenticationBuilder builder,
|
||||
Action<McpAuthenticationOptions>? configureOptions = null)
|
||||
{
|
||||
return AddMcp(
|
||||
builder,
|
||||
McpAuthenticationDefaults.AuthenticationScheme,
|
||||
McpAuthenticationDefaults.DisplayName,
|
||||
configureOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds MCP authorization support to the application with a custom scheme name.
|
||||
/// </summary>
|
||||
/// <param name="builder">The authentication builder.</param>
|
||||
/// <param name="authenticationScheme">The authentication scheme name to use.</param>
|
||||
/// <param name="displayName">The display name for the authentication scheme.</param>
|
||||
/// <param name="configureOptions">An action to configure MCP authentication options.</param>
|
||||
/// <returns>The authentication builder for chaining.</returns>
|
||||
public static AuthenticationBuilder AddMcp(
|
||||
this AuthenticationBuilder builder,
|
||||
string authenticationScheme,
|
||||
string displayName,
|
||||
Action<McpAuthenticationOptions>? configureOptions = null)
|
||||
{
|
||||
return builder.AddScheme<McpAuthenticationOptions, McpAuthenticationHandler>(
|
||||
authenticationScheme,
|
||||
displayName,
|
||||
configureOptions);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ModelContextProtocol.Authentication;
|
||||
using System.Text.Encodings.Web;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication handler for MCP protocol that adds resource metadata to challenge responses
|
||||
/// and handles resource metadata endpoint requests.
|
||||
/// </summary>
|
||||
public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationOptions>, IAuthenticationRequestHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpAuthenticationHandler"/> class.
|
||||
/// </summary>
|
||||
public McpAuthenticationHandler(
|
||||
IOptionsMonitor<McpAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> HandleRequestAsync()
|
||||
{
|
||||
// Check if the request is for the resource metadata endpoint
|
||||
string requestPath = Request.Path.Value ?? string.Empty;
|
||||
|
||||
string expectedMetadataPath = Options.ResourceMetadataUri?.ToString() ?? string.Empty;
|
||||
if (Options.ResourceMetadataUri != null && !Options.ResourceMetadataUri.IsAbsoluteUri)
|
||||
{
|
||||
// For relative URIs, it's just the path component.
|
||||
expectedMetadataPath = Options.ResourceMetadataUri.OriginalString;
|
||||
}
|
||||
|
||||
// If the path doesn't match, let the request continue through the pipeline
|
||||
if (!string.Equals(requestPath, expectedMetadataPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cancellationToken = Request.HttpContext.RequestAborted;
|
||||
await HandleResourceMetadataRequestAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base URL from the current request, including scheme, host, and path base.
|
||||
/// </summary>
|
||||
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute URI for the resource metadata endpoint.
|
||||
/// </summary>
|
||||
private string GetAbsoluteResourceMetadataUri()
|
||||
{
|
||||
var resourceMetadataUri = Options.ResourceMetadataUri;
|
||||
|
||||
string currentPath = resourceMetadataUri?.ToString() ?? string.Empty;
|
||||
|
||||
if (resourceMetadataUri != null && resourceMetadataUri.IsAbsoluteUri)
|
||||
{
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
// For relative URIs, combine with the base URL
|
||||
string baseUrl = GetBaseUrl();
|
||||
string relativePath = resourceMetadataUri?.OriginalString.TrimStart('/') ?? string.Empty;
|
||||
|
||||
if (!Uri.TryCreate($"{baseUrl.TrimEnd('/')}/{relativePath}", UriKind.Absolute, out var absoluteUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Could not create absolute URI for resource metadata. Base URL: {baseUrl}, Relative Path: {relativePath}");
|
||||
}
|
||||
|
||||
return absoluteUri.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the resource metadata request.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
private async Task HandleResourceMetadataRequestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resourceMetadata = Options.ResourceMetadata;
|
||||
|
||||
if (Options.Events.OnResourceMetadataRequest is not null)
|
||||
{
|
||||
var context = new ResourceMetadataRequestContext(Request.HttpContext, Scheme, Options)
|
||||
{
|
||||
ResourceMetadata = CloneResourceMetadata(resourceMetadata),
|
||||
};
|
||||
|
||||
await Options.Events.OnResourceMetadataRequest(context);
|
||||
}
|
||||
|
||||
|
||||
if (resourceMetadata == null)
|
||||
{
|
||||
throw new InvalidOperationException("ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata.");
|
||||
}
|
||||
|
||||
await Results.Json(resourceMetadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))).ExecuteAsync(Context);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
// If no forwarding is configured, this handler doesn't perform authentication
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() => AuthenticateResult.NoResult();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
// Get the absolute URI for the resource metadata
|
||||
string rawPrmDocumentUri = GetAbsoluteResourceMetadataUri();
|
||||
|
||||
properties ??= new AuthenticationProperties();
|
||||
|
||||
// Store the resource_metadata in properties in case other handlers need it
|
||||
properties.Items["resource_metadata"] = rawPrmDocumentUri;
|
||||
|
||||
// Add the WWW-Authenticate header with Bearer scheme and resource metadata
|
||||
string headerValue = $"Bearer realm=\"{Scheme.Name}\", resource_metadata=\"{rawPrmDocumentUri}\"";
|
||||
Response.Headers.Append("WWW-Authenticate", headerValue);
|
||||
|
||||
return base.HandleChallengeAsync(properties);
|
||||
}
|
||||
|
||||
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata)
|
||||
{
|
||||
if (resourceMetadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ProtectedResourceMetadata
|
||||
{
|
||||
Resource = resourceMetadata.Resource,
|
||||
AuthorizationServers = [.. resourceMetadata.AuthorizationServers],
|
||||
BearerMethodsSupported = [.. resourceMetadata.BearerMethodsSupported],
|
||||
ScopesSupported = [.. resourceMetadata.ScopesSupported],
|
||||
JwksUri = resourceMetadata.JwksUri,
|
||||
ResourceSigningAlgValuesSupported = resourceMetadata.ResourceSigningAlgValuesSupported is not null ? [.. resourceMetadata.ResourceSigningAlgValuesSupported] : null,
|
||||
ResourceName = resourceMetadata.ResourceName,
|
||||
ResourceDocumentation = resourceMetadata.ResourceDocumentation,
|
||||
ResourcePolicyUri = resourceMetadata.ResourcePolicyUri,
|
||||
ResourceTosUri = resourceMetadata.ResourceTosUri,
|
||||
TlsClientCertificateBoundAccessTokens = resourceMetadata.TlsClientCertificateBoundAccessTokens,
|
||||
AuthorizationDetailsTypesSupported = resourceMetadata.AuthorizationDetailsTypesSupported is not null ? [.. resourceMetadata.AuthorizationDetailsTypesSupported] : null,
|
||||
DpopSigningAlgValuesSupported = resourceMetadata.DpopSigningAlgValuesSupported is not null ? [.. resourceMetadata.DpopSigningAlgValuesSupported] : null,
|
||||
DpopBoundAccessTokensRequired = resourceMetadata.DpopBoundAccessTokensRequired
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using ModelContextProtocol.Authentication;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Options for the MCP authentication handler.
|
||||
/// </summary>
|
||||
public class McpAuthenticationOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
private static readonly Uri DefaultResourceMetadataUri = new("/.well-known/oauth-protected-resource", UriKind.Relative);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpAuthenticationOptions"/> class.
|
||||
/// </summary>
|
||||
public McpAuthenticationOptions()
|
||||
{
|
||||
// "Bearer" is JwtBearerDefaults.AuthenticationScheme, but we don't have a reference to the JwtBearer package here.
|
||||
ForwardAuthenticate = "Bearer";
|
||||
ResourceMetadataUri = DefaultResourceMetadataUri;
|
||||
Events = new McpAuthenticationEvents();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the events used to handle authentication events.
|
||||
/// </summary>
|
||||
public new McpAuthenticationEvents Events
|
||||
{
|
||||
get { return (McpAuthenticationEvents)base.Events!; }
|
||||
set { base.Events = value; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The URI to the resource metadata document.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This URI will be included in the WWW-Authenticate header when a 401 response is returned.
|
||||
/// </remarks>
|
||||
public Uri ResourceMetadataUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protected resource metadata.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This contains the OAuth metadata for the protected resource, including authorization servers,
|
||||
/// supported scopes, and other information needed for clients to authenticate.
|
||||
/// </remarks>
|
||||
public ProtectedResourceMetadata? ResourceMetadata { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using ModelContextProtocol.Authentication;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Context for resource metadata request events.
|
||||
/// </summary>
|
||||
public class ResourceMetadataRequestContext : HandleRequestContext<McpAuthenticationOptions>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ResourceMetadataRequestContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context.</param>
|
||||
/// <param name="scheme">The authentication scheme.</param>
|
||||
/// <param name="options">The authentication options.</param>
|
||||
public ResourceMetadataRequestContext(
|
||||
HttpContext context,
|
||||
AuthenticationScheme scheme,
|
||||
McpAuthenticationOptions options)
|
||||
: base(context, scheme, options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protected resource metadata for the current request.
|
||||
/// </summary>
|
||||
public ProtectedResourceMetadata? ResourceMetadata { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating handler that adds authentication tokens to requests and handles 401 responses.
|
||||
/// </summary>
|
||||
internal sealed class AuthenticatingMcpHttpClient(HttpClient httpClient, ClientOAuthProvider credentialProvider) : McpHttpClient(httpClient)
|
||||
{
|
||||
// Select first supported scheme as the default
|
||||
private string _currentScheme = credentialProvider.SupportedSchemes.FirstOrDefault() ??
|
||||
throw new ArgumentException("Authorization provider must support at least one authentication scheme.", nameof(credentialProvider));
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request with authentication handling.
|
||||
/// </summary>
|
||||
internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Headers.Authorization == null)
|
||||
{
|
||||
await AddAuthorizationHeaderAsync(request, _currentScheme, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var response = await base.SendAsync(request, message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return await HandleUnauthorizedResponseAsync(request, message, response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a 401 Unauthorized response by attempting to authenticate and retry the request.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> HandleUnauthorizedResponseAsync(
|
||||
HttpRequestMessage originalRequest,
|
||||
JsonRpcMessage? originalJsonRpcMessage,
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Gather the schemes the server wants us to use from WWW-Authenticate headers
|
||||
var serverSchemes = ExtractServerSupportedSchemes(response);
|
||||
|
||||
if (!serverSchemes.Contains(_currentScheme))
|
||||
{
|
||||
// Find the first server scheme that's in our supported set
|
||||
var bestSchemeMatch = serverSchemes.Intersect(credentialProvider.SupportedSchemes, StringComparer.OrdinalIgnoreCase).FirstOrDefault();
|
||||
|
||||
if (bestSchemeMatch is not null)
|
||||
{
|
||||
_currentScheme = bestSchemeMatch;
|
||||
}
|
||||
else if (serverSchemes.Count > 0)
|
||||
{
|
||||
// If no match was found, either throw an exception or use default
|
||||
throw new McpException(
|
||||
$"The server does not support any of the provided authentication schemes." +
|
||||
$"Server supports: [{string.Join(", ", serverSchemes)}], " +
|
||||
$"Provider supports: [{string.Join(", ", credentialProvider.SupportedSchemes)}].");
|
||||
}
|
||||
}
|
||||
|
||||
// Try to handle the 401 response with the selected scheme
|
||||
await credentialProvider.HandleUnauthorizedResponseAsync(_currentScheme, response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri);
|
||||
|
||||
// Copy headers except Authorization which we'll set separately
|
||||
foreach (var header in originalRequest.Headers)
|
||||
{
|
||||
if (!header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
retryRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
|
||||
await AddAuthorizationHeaderAsync(retryRequest, _currentScheme, cancellationToken).ConfigureAwait(false);
|
||||
return await base.SendAsync(retryRequest, originalJsonRpcMessage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the authentication schemes that the server supports from the WWW-Authenticate headers.
|
||||
/// </summary>
|
||||
private static HashSet<string> ExtractServerSupportedSchemes(HttpResponseMessage response)
|
||||
{
|
||||
var serverSchemes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var header in response.Headers.WwwAuthenticate)
|
||||
{
|
||||
serverSchemes.Add(header.Scheme);
|
||||
}
|
||||
|
||||
return serverSchemes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an authorization header to the request.
|
||||
/// </summary>
|
||||
private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, string scheme, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.RequestUri is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var token = await credentialProvider.GetCredentialAsync(scheme, request.RequestUri, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
|
||||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a method that handles the OAuth authorization URL and returns the authorization code.
|
||||
/// </summary>
|
||||
/// <param name="authorizationUri">The authorization URL that the user needs to visit.</param>
|
||||
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains the authorization code if successful, or null if the operation failed or was cancelled.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This delegate provides SDK consumers with full control over how the OAuth authorization flow is handled.
|
||||
/// Implementers can choose to:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Start a local HTTP server and open a browser (default behavior)</description></item>
|
||||
/// <item><description>Display the authorization URL to the user for manual handling</description></item>
|
||||
/// <item><description>Integrate with a custom UI or authentication flow</description></item>
|
||||
/// <item><description>Use a different redirect mechanism altogether</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// The implementation should handle user interaction to visit the authorization URL and extract
|
||||
/// the authorization code from the callback. The authorization code is typically provided as
|
||||
/// a query parameter in the redirect URI callback.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public delegate Task<string?> AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken);
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the metadata about an OAuth authorization server.
|
||||
/// </summary>
|
||||
internal sealed class AuthorizationServerMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// The authorization endpoint URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("authorization_endpoint")]
|
||||
public Uri AuthorizationEndpoint { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The token endpoint URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("token_endpoint")]
|
||||
public Uri TokenEndpoint { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The registration endpoint URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("registration_endpoint")]
|
||||
public Uri? RegistrationEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The revocation endpoint URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("revocation_endpoint")]
|
||||
public Uri? RevocationEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The response types supported by the authorization server.
|
||||
/// </summary>
|
||||
[JsonPropertyName("response_types_supported")]
|
||||
public List<string>? ResponseTypesSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The grant types supported by the authorization server.
|
||||
/// </summary>
|
||||
[JsonPropertyName("grant_types_supported")]
|
||||
public List<string>? GrantTypesSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The token endpoint authentication methods supported by the authorization server.
|
||||
/// </summary>
|
||||
[JsonPropertyName("token_endpoint_auth_methods_supported")]
|
||||
public List<string>? TokenEndpointAuthMethodsSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The code challenge methods supported by the authorization server.
|
||||
/// </summary>
|
||||
[JsonPropertyName("code_challenge_methods_supported")]
|
||||
public List<string>? CodeChallengeMethodsSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The issuer URI of the authorization server.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuer")]
|
||||
public Uri? Issuer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The scopes supported by the authorization server.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scopes_supported")]
|
||||
public List<string>? ScopesSupported { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue