From 0bd80e6696e51bf5bd2171482b28716cc6c9ac17 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 1 May 2025 21:14:03 -0700 Subject: [PATCH 001/128] Experimental changes in a different direction --- .gitignore | 5 +- samples/SecureWeatherClient/Program.cs | 136 +++++++ .../SecureWeatherClient.csproj | 15 + samples/SecureWeatherServer/Program.cs | 242 ++++++++++++ .../SecureWeatherServer.csproj | 14 + .../Auth/McpAuthenticationHandler.cs | 55 +++ .../Auth/McpAuthorizationExtensions.cs | 98 +++++ .../HttpMcpServerBuilderExtensions.cs | 60 +++ .../McpEndpointRouteBuilderExtensions.cs | 16 + .../Auth/AuthorizationHandlers.cs | 126 ++++++ .../Auth/AuthorizationServerMetadata.cs | 90 +++++ .../Auth/ClientRegistrationRequest.cs | 100 +++++ .../Auth/ClientRegistrationResponse.cs | 34 ++ .../Auth/McpClientExtensions.cs | 179 +++++++++ .../Auth/OAuthAuthenticationService.cs | 373 ++++++++++++++++++ .../Auth/OAuthDelegatingHandler.cs | 143 +++++++ .../Auth/OAuthTokenResponse.cs | 46 +++ .../Auth/ProtectedResourceMetadata.cs | 39 ++ .../Utils/Json/McpJsonUtilities.cs | 7 + 19 files changed, 1777 insertions(+), 1 deletion(-) create mode 100644 samples/SecureWeatherClient/Program.cs create mode 100644 samples/SecureWeatherClient/SecureWeatherClient.csproj create mode 100644 samples/SecureWeatherServer/Program.cs create mode 100644 samples/SecureWeatherServer/SecureWeatherServer.csproj create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs create mode 100644 src/ModelContextProtocol/Auth/AuthorizationHandlers.cs create mode 100644 src/ModelContextProtocol/Auth/AuthorizationServerMetadata.cs create mode 100644 src/ModelContextProtocol/Auth/ClientRegistrationRequest.cs create mode 100644 src/ModelContextProtocol/Auth/ClientRegistrationResponse.cs create mode 100644 src/ModelContextProtocol/Auth/McpClientExtensions.cs create mode 100644 src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs create mode 100644 src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs create mode 100644 src/ModelContextProtocol/Auth/OAuthTokenResponse.cs create mode 100644 src/ModelContextProtocol/Auth/ProtectedResourceMetadata.cs diff --git a/.gitignore b/.gitignore index 171615f9..c46bb666 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,7 @@ docs/api # Rider .idea/ -.idea_modules/ \ No newline at end of file +.idea_modules/ + +# Misc project metadata +.specs/ \ No newline at end of file diff --git a/samples/SecureWeatherClient/Program.cs b/samples/SecureWeatherClient/Program.cs new file mode 100644 index 00000000..78955f97 --- /dev/null +++ b/samples/SecureWeatherClient/Program.cs @@ -0,0 +1,136 @@ +// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\samples\SecureWeatherClient\Program.cs +using System.Net.Http; +using System.Threading.Tasks; +using ModelContextProtocol.Auth; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol.Transport; + +namespace SecureWeatherClient; + +class Program +{ + // The URI for our OAuth redirect - in a real app, this would be a registered URI or a local server + private static readonly Uri RedirectUri = new("http://localhost:8888/oauth-callback"); + + static async Task Main(string[] args) + { + Console.WriteLine("MCP Secure Weather Client with OAuth Authentication"); + Console.WriteLine("=================================================="); + Console.WriteLine(); + + // Create an HTTP client with OAuth handling + var oauthHandler = new OAuthDelegatingHandler( + redirectUri: RedirectUri, + clientName: "SecureWeatherClient", + scopes: new[] { "weather.read" }, + authorizationHandler: HandleAuthorizationRequestAsync); + + var httpClient = new HttpClient(oauthHandler); + var serverUrl = "http://localhost:5000"; // Default server URL + + // Allow the user to specify a different server URL + Console.WriteLine($"Server URL (press Enter for default: {serverUrl}):"); + var userInput = Console.ReadLine(); + if (!string.IsNullOrWhiteSpace(userInput)) + { + serverUrl = userInput; + } + + Console.WriteLine(); + Console.WriteLine($"Connecting to weather server at {serverUrl}..."); + + // Create an MCP client with the server URL + var client = new McpClient(new Uri(serverUrl), httpClient); + + try + { + // Get the list of available tools + var tools = await client.GetToolsAsync(); + if (tools.Count == 0) + { + Console.WriteLine("No tools available on the server."); + return; + } + + Console.WriteLine($"Found {tools.Count} tools on the server."); + Console.WriteLine(); + + // Find the weather tool + var weatherTool = tools.FirstOrDefault(t => t.Name == "get_weather"); + if (weatherTool == null) + { + Console.WriteLine("The server does not provide a weather tool."); + return; + } + + // Get the weather for different locations + string[] locations = { "New York", "London", "Tokyo", "Sydney", "Moscow" }; + + foreach (var location in locations) + { + try + { + Console.WriteLine($"Getting weather for {location}..."); + var result = await client.InvokeToolAsync(weatherTool.Name, new Dictionary + { + ["location"] = location + }); + + if (result.TryGetValue("temperature", out var temperature) && + result.TryGetValue("conditions", out var conditions) && + result.TryGetValue("humidity", out var humidity) && + result.TryGetValue("windSpeed", out var windSpeed)) + { + Console.WriteLine($"Weather in {location}:"); + Console.WriteLine($" Temperature: {temperature}°C"); + Console.WriteLine($" Conditions: {conditions}"); + Console.WriteLine($" Humidity: {humidity}%"); + Console.WriteLine($" Wind speed: {windSpeed} km/h"); + } + else + { + Console.WriteLine($"Invalid response format for {location}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error getting weather for {location}: {ex.Message}"); + } + + Console.WriteLine(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + + Console.WriteLine("Press any key to exit..."); + Console.ReadKey(); + } + + /// + /// Handles the OAuth authorization request by showing the URL to the user and getting the authorization code. + /// In a real application, this would launch a browser and listen for the callback. + /// + private static Task HandleAuthorizationRequestAsync(Uri authorizationUri) + { + Console.WriteLine(); + Console.WriteLine("Authentication Required"); + Console.WriteLine("======================"); + Console.WriteLine(); + Console.WriteLine("Please open the following URL in your browser to authenticate:"); + Console.WriteLine(authorizationUri); + Console.WriteLine(); + Console.WriteLine("After authentication, you will be redirected to a page with a code."); + Console.WriteLine("Please enter the code parameter from the URL:"); + + var authorizationCode = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(authorizationCode)) + { + throw new InvalidOperationException("Authorization code is required."); + } + + return Task.FromResult(authorizationCode); + } +} \ No newline at end of file diff --git a/samples/SecureWeatherClient/SecureWeatherClient.csproj b/samples/SecureWeatherClient/SecureWeatherClient.csproj new file mode 100644 index 00000000..04387e91 --- /dev/null +++ b/samples/SecureWeatherClient/SecureWeatherClient.csproj @@ -0,0 +1,15 @@ + + + + + Exe + net8.0 + enable + enable + + + + + + + \ No newline at end of file diff --git a/samples/SecureWeatherServer/Program.cs b/samples/SecureWeatherServer/Program.cs new file mode 100644 index 00000000..a657707d --- /dev/null +++ b/samples/SecureWeatherServer/Program.cs @@ -0,0 +1,242 @@ +// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\samples\SecureWeatherServer\Program.cs +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Auth; +using ModelContextProtocol.Protocol.Messages; +using ModelContextProtocol.Server; + +var builder = WebApplication.CreateBuilder(args); + +// Add MCP server with OAuth authorization +builder.Services.AddMcpServer() + .WithHttpTransport() + .WithOAuthAuthorization(metadata => + { + // Configure the resource metadata + metadata.AuthorizationServers.Add(new Uri("https://auth.example.com")); + metadata.ScopesSupported.AddRange(new[] { "weather.read", "weather.write" }); + metadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); + }); + +// Build the app +var app = builder.Build(); + +// Enable CORS for development +app.UseCors(policy => policy + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader()); + +// Configure the HTTP request pipeline +app.UseAuthentication(); +app.UseAuthorization(); + +// Map MCP endpoints with authorization +app.MapMcpWithAuthorization(); + +// Define weather tool +var weatherTool = new McpTool("get_weather", "Get the current weather for a location") + .WithParameter("location", "The location to get the weather for", typeof(string), required: true); + +// Define weather server logic +app.UseMiddleware(options => +{ + options.RegisterTool(weatherTool, async (McpToolInvokeParameters parameters, CancellationToken ct) => + { + if (!parameters.TryGetParameterValue("location", out var location)) + { + return McpToolResult.Error("Location parameter is required"); + } + + // In a real implementation, you would get the weather for the location + // For this example, we'll just return a random weather + var weather = GetRandomWeather(location); + + return McpToolResult.Success(new + { + location, + temperature = weather.Temperature, + conditions = weather.Conditions, + humidity = weather.Humidity, + windSpeed = weather.WindSpeed + }); + }); +}); + +// Run the app +app.Run(); + +// Helper method to generate random weather +(double Temperature, string Conditions, int Humidity, double WindSpeed) GetRandomWeather(string location) +{ + var random = new Random(); + var conditions = new[] { "Sunny", "Cloudy", "Rainy", "Snowy", "Foggy", "Windy" }; + + return ( + Temperature: Math.Round(random.NextDouble() * 40 - 10, 1), // -10 to 30 degrees + Conditions: conditions[random.Next(conditions.Length)], + Humidity: random.Next(30, 95), + WindSpeed: Math.Round(random.NextDouble() * 30, 1) + ); +} + +// Middleware to handle server invocations +public class ServerInvokeMiddleware +{ + private readonly RequestDelegate _next; + private readonly McpServerInvokeOptions _options; + + public ServerInvokeMiddleware(RequestDelegate next, McpServerInvokeOptions options) + { + _next = next; + _options = options; + } + + public ServerInvokeMiddleware(RequestDelegate next, Action configureOptions) + : this(next, new McpServerInvokeOptions(configureOptions)) + { + } + + public async Task InvokeAsync(HttpContext context) + { + // Set up the MCP server with the registered tools + if (context.Features.Get() is McpServer server) + { + foreach (var registration in _options.ToolRegistrations) + { + server.RegisterToolHandler(registration.Tool.Definition, registration.Handler); + } + } + + await _next(context); + } +} + +// Helper classes for tool registration +public class McpServerInvokeOptions +{ + public List ToolRegistrations { get; } = new(); + + public McpServerInvokeOptions() { } + + public McpServerInvokeOptions(Action configure) + { + configure(this); + } + + public void RegisterTool(McpTool tool, Func> handler) + { + ToolRegistrations.Add(new ToolRegistration(tool, handler)); + } +} + +public class ToolRegistration +{ + public McpTool Tool { get; } + public Func> Handler { get; } + + public ToolRegistration( + McpTool tool, + Func> handler) + { + Tool = tool; + Handler = handler; + } +} + +// Helper class to simplify tool registration and parameter handling +public class McpTool +{ + public ToolDefinition Definition { get; } + + public McpTool(string name, string description) + { + Definition = new ToolDefinition + { + Name = name, + Description = description, + Parameters = new ToolParameterDefinition + { + Properties = {}, + Required = new List() + } + }; + } + + public McpTool WithParameter(string name, string description, Type type, bool required = false) + { + Definition.Parameters.Properties[name] = new ToolPropertyDefinition + { + Description = description, + Type = GetJsonSchemaType(type) + }; + + if (required) + { + Definition.Parameters.Required.Add(name); + } + + return this; + } + + private static string GetJsonSchemaType(Type type) + { + return type.Name.ToLowerInvariant() switch + { + "string" => "string", + "int32" or "int64" or "int" or "long" or "double" or "float" or "decimal" => "number", + "boolean" => "boolean", + _ => "object" + }; + } +} + +// Helper class for the tool invocation parameters +public class McpToolInvokeParameters +{ + private readonly Dictionary _parameters; + + public McpToolInvokeParameters(Dictionary parameters) + { + _parameters = parameters; + } + + public bool TryGetParameterValue(string name, out T value) + { + if (_parameters.TryGetValue(name, out var objValue) && objValue is T typedValue) + { + value = typedValue; + return true; + } + + value = default!; + return false; + } + + public T? GetParameterValue(string name) + { + if (_parameters.TryGetValue(name, out var value) && value is T typedValue) + { + return typedValue; + } + + return default; + } +} + +// Helper class for the tool result +public class McpToolResult +{ + public object? Result { get; } + public string? Error { get; } + public bool IsError => Error != null; + + private McpToolResult(object? result, string? error) + { + Result = result; + Error = error; + } + + public static McpToolResult Success(object? result = null) => new McpToolResult(result, null); + public static McpToolResult Error(string error) => new McpToolResult(null, error); +} \ No newline at end of file diff --git a/samples/SecureWeatherServer/SecureWeatherServer.csproj b/samples/SecureWeatherServer/SecureWeatherServer.csproj new file mode 100644 index 00000000..4850a605 --- /dev/null +++ b/samples/SecureWeatherServer/SecureWeatherServer.csproj @@ -0,0 +1,14 @@ + + + + + net8.0 + enable + enable + + + + + + + \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs new file mode 100644 index 00000000..e78d8479 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -0,0 +1,55 @@ +// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol.AspNetCore\Auth\McpAuthenticationHandler.cs +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text.Encodings.Web; + +namespace ModelContextProtocol.AspNetCore.Auth; + +/// +/// Authentication handler for MCP protocol that adds resource metadata to challenge responses. +/// +public class McpAuthenticationHandler : AuthenticationHandler +{ + /// + /// Initializes a new instance of the class. + /// + public McpAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + /// + protected override Task HandleAuthenticateAsync() + { + // This handler doesn't perform authentication - it only adds resource metadata to challenges + // The actual authentication will be handled by the bearer token handler or other authentication handlers + return Task.FromResult(AuthenticateResult.NoResult()); + } + + /// + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + if (Options.ResourceMetadataUri != null) + { + Response.Headers.WWWAuthenticate = $"Bearer resource_metadata=\"{Options.ResourceMetadataUri}\""; + } + + return Task.CompletedTask; + } +} + +/// +/// Options for the MCP authentication handler. +/// +public class McpAuthenticationOptions : AuthenticationSchemeOptions +{ + /// + /// The URI to the resource metadata document. + /// + public Uri? ResourceMetadataUri { get; set; } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs new file mode 100644 index 00000000..77d8bd7a --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -0,0 +1,98 @@ +// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol.AspNetCore\Auth\McpAuthorizationExtensions.cs +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ModelContextProtocol.Auth; + +namespace ModelContextProtocol.AspNetCore.Auth; + +/// +/// Extension methods for adding MCP authorization support to ASP.NET Core applications. +/// +public static class McpAuthorizationExtensions +{ + /// + /// Adds MCP authorization support to the application. + /// + /// The authentication builder. + /// An action to configure MCP authentication options. + /// The authentication builder for chaining. + public static AuthenticationBuilder AddMcpAuthorization( + this AuthenticationBuilder builder, + Action? configureOptions = null) + { + builder.Services.TryAddSingleton(); + + return builder.AddScheme( + "McpAuth", + "MCP Authentication", + configureOptions ?? (options => { })); + } + + /// + /// Maps the resource metadata endpoint for MCP OAuth authorization. + /// + /// The endpoint route builder. + /// The route pattern. + /// An action to configure the resource metadata. + /// An endpoint convention builder for further configuration. + public static IEndpointConventionBuilder MapMcpResourceMetadata( + this IEndpointRouteBuilder endpoints, + string pattern = "/.well-known/oauth-protected-resource", + Action? configure = null) + { + var metadataService = endpoints.ServiceProvider.GetRequiredService(); + + if (configure != null) + { + metadataService.ConfigureMetadata(configure); + } + + return endpoints.MapGet(pattern, async (HttpContext context) => + { + var metadata = metadataService.GetMetadata(); + + // Set default resource if not set + if (metadata.Resource == null) + { + var request = context.Request; + var hostString = request.Host.Value; + var scheme = request.Scheme; + metadata.Resource = new Uri($"{scheme}://{hostString}"); + } + + return Results.Json(metadata); + }) + .AllowAnonymous() + .WithDisplayName("MCP Resource Metadata"); + } +} + +/// +/// Service for managing MCP resource metadata. +/// +public class ResourceMetadataService +{ + private readonly ProtectedResourceMetadata _metadata = new(); + + /// + /// Configures the resource metadata. + /// + /// Configuration action. + public void ConfigureMetadata(Action configure) + { + configure(_metadata); + } + + /// + /// Gets the resource metadata. + /// + /// The resource metadata. + public ProtectedResourceMetadata GetMetadata() + { + return _metadata; + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 8bff4596..ca128cfe 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using ModelContextProtocol.AspNetCore; +using ModelContextProtocol.AspNetCore.Auth; +using ModelContextProtocol.Auth; using ModelContextProtocol.Server; namespace Microsoft.Extensions.DependencyInjection; @@ -34,4 +36,62 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder return builder; } + + /// + /// Adds OAuth authorization support to the MCP server. + /// + /// The builder instance. + /// An action to configure the resource metadata. + /// An action to configure authentication options. + /// The builder provided in . + /// is . + public static IMcpServerBuilder WithAuthorization( + this IMcpServerBuilder builder, + Action? configureMetadata = null, + Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(builder); + + // Register the resource metadata service + builder.Services.TryAddSingleton(); + + // Configure the resource metadata if provided + if (configureMetadata != null) + { + builder.Services.Configure(service => + { + service.ConfigureMetadata(configureMetadata); + }); + } + + // Mark the service as having authorization enabled + builder.Services.AddSingleton(); + + // Add authentication with the MCP authentication handler + builder.Services.AddAuthentication() + .AddMcpAuthorization(options => + { + // Default to the standard OAuth protected resource endpoint + options.ResourceMetadataUri = new Uri("/.well-known/oauth-protected-resource", UriKind.Relative); + + // Apply custom configuration if provided + configureOptions?.Invoke(options); + }); + + // Add authorization services + builder.Services.AddAuthorization(options => + { + options.AddPolicy("McpAuth", policy => + { + policy.RequireAuthenticatedUser(); + }); + }); + + return builder; + } } + +/// +/// Marker class to indicate that MCP authorization has been configured. +/// +internal class McpAuthorizationMarker { } diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index 0eefa52f..2286dabd 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore; +using ModelContextProtocol.AspNetCore.Auth; +using ModelContextProtocol.Auth; using ModelContextProtocol.Protocol.Messages; using System.Diagnostics.CodeAnalysis; @@ -50,6 +52,20 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo .WithMetadata(new AcceptsMetadata(["application/json"])) .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted)); + // Check if authentication/authorization is configured + var authMarker = endpoints.ServiceProvider.GetService(); + if (authMarker != null) + { + // Authorization is configured, so automatically map the OAuth protected resource endpoint + var resourceMetadataService = endpoints.ServiceProvider.GetRequiredService(); + + // Map the OAuth protected resource endpoint + endpoints.MapMcpResourceMetadata("/.well-known/oauth-protected-resource"); + + // Apply authorization to MCP endpoints + mcpGroup.RequireAuthorization("McpAuth"); + } + return mcpGroup; } } diff --git a/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs b/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs new file mode 100644 index 00000000..d937ef72 --- /dev/null +++ b/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs @@ -0,0 +1,126 @@ +// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\AuthorizationHandlers.cs +using System.Security.Cryptography; +using System.Text; + +namespace ModelContextProtocol.Auth; + +/// +/// Provides utilities for PKCE (Proof Key for Code Exchange) in OAuth authorization flows. +/// +public static class PkceUtility +{ + /// + /// Represents the PKCE code challenge and verifier for an authorization flow. + /// + public class PkceValues + { + /// + /// The code verifier used to generate the code challenge. + /// + public string CodeVerifier { get; } + + /// + /// The code challenge sent to the authorization server. + /// + public string CodeChallenge { get; } + + /// + /// Initializes a new instance of the class. + /// + public PkceValues(string codeVerifier, string codeChallenge) + { + CodeVerifier = codeVerifier; + CodeChallenge = codeChallenge; + } + } + + /// + /// Generates new PKCE values. + /// + /// A instance containing the code verifier and challenge. + public static PkceValues GeneratePkceValues() + { + var codeVerifier = GenerateCodeVerifier(); + var codeChallenge = GenerateCodeChallenge(codeVerifier); + return new PkceValues(codeVerifier, codeChallenge); + } + + private static string GenerateCodeVerifier() + { + // Generate a cryptographically random code verifier + var bytes = new byte[64]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + + // Base64url encode the random bytes + var base64 = Convert.ToBase64String(bytes); + var base64Url = base64 + .Replace('+', '-') + .Replace('/', '_') + .Replace("=", ""); + + return base64Url; + } + + private static string GenerateCodeChallenge(string codeVerifier) + { + // Create code challenge using S256 method + using var sha256 = SHA256.Create(); + var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); + + // Base64url encode the hash + var base64 = Convert.ToBase64String(challengeBytes); + var base64Url = base64 + .Replace('+', '-') + .Replace('/', '_') + .Replace("=", ""); + + return base64Url; + } +} + +/// +/// Configuration options for the authorization code flow. +/// +public class AuthorizationCodeOptions +{ + /// + /// The client ID. + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// The client secret. + /// + public string? ClientSecret { get; set; } + + /// + /// The redirect URI. + /// + public Uri RedirectUri { get; set; } = null!; + + /// + /// The authorization endpoint. + /// + public Uri AuthorizationEndpoint { get; set; } = null!; + + /// + /// The token endpoint. + /// + public Uri TokenEndpoint { get; set; } = null!; + + /// + /// The scope to request. + /// + public string? Scope { get; set; } + + /// + /// PKCE values for the authorization flow. + /// + public PkceUtility.PkceValues PkceValues { get; set; } = null!; + + /// + /// A state value to protect against CSRF attacks. + /// + public string State { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/AuthorizationServerMetadata.cs b/src/ModelContextProtocol/Auth/AuthorizationServerMetadata.cs new file mode 100644 index 00000000..9d1228f6 --- /dev/null +++ b/src/ModelContextProtocol/Auth/AuthorizationServerMetadata.cs @@ -0,0 +1,90 @@ +// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\AuthorizationServerMetadata.cs +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Auth; + +/// +/// Represents the metadata about an OAuth authorization server. +/// +public class AuthorizationServerMetadata +{ + /// + /// The authorization endpoint URI. + /// + [JsonPropertyName("authorization_endpoint")] + public Uri AuthorizationEndpoint { get; set; } = null!; + + /// + /// The token endpoint URI. + /// + [JsonPropertyName("token_endpoint")] + public Uri TokenEndpoint { get; set; } = null!; + + /// + /// The registration endpoint URI. + /// + [JsonPropertyName("registration_endpoint")] + public Uri? RegistrationEndpoint { get; set; } + + /// + /// The revocation endpoint URI. + /// + [JsonPropertyName("revocation_endpoint")] + public Uri? RevocationEndpoint { get; set; } + + /// + /// The response types supported by the authorization server. + /// + [JsonPropertyName("response_types_supported")] + public List? ResponseTypesSupported { get; set; } + + /// + /// The grant types supported by the authorization server. + /// + [JsonPropertyName("grant_types_supported")] + public List? GrantTypesSupported { get; set; } + + /// + /// The token endpoint authentication methods supported by the authorization server. + /// + [JsonPropertyName("token_endpoint_auth_methods_supported")] + public List? TokenEndpointAuthMethodsSupported { get; set; } + + /// + /// The code challenge methods supported by the authorization server. + /// + [JsonPropertyName("code_challenge_methods_supported")] + public List? CodeChallengeMethodsSupported { get; set; } + + /// + /// The issuer URI of the authorization server. + /// + [JsonPropertyName("issuer")] + public Uri? Issuer { get; set; } + + /// + /// The scopes supported by the authorization server. + /// + [JsonPropertyName("scopes_supported")] + public List? ScopesSupported { get; set; } + + /// + /// Gets the response types supported by the authorization server or returns the default. + /// + public IReadOnlyList GetResponseTypesSupported() => ResponseTypesSupported ?? new List { "code" }; + + /// + /// Gets the grant types supported by the authorization server or returns the default. + /// + public IReadOnlyList GetGrantTypesSupported() => GrantTypesSupported ?? new List { "authorization_code", "refresh_token" }; + + /// + /// Gets the token endpoint authentication methods supported by the authorization server or returns the default. + /// + public IReadOnlyList GetTokenEndpointAuthMethodsSupported() => TokenEndpointAuthMethodsSupported ?? new List { "client_secret_basic" }; + + /// + /// Gets the code challenge methods supported by the authorization server or returns the default. + /// + public IReadOnlyList GetCodeChallengeMethodsSupported() => CodeChallengeMethodsSupported ?? new List { "S256" }; +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/ClientRegistrationRequest.cs b/src/ModelContextProtocol/Auth/ClientRegistrationRequest.cs new file mode 100644 index 00000000..96fc249d --- /dev/null +++ b/src/ModelContextProtocol/Auth/ClientRegistrationRequest.cs @@ -0,0 +1,100 @@ +// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\ClientRegistration.cs +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Auth; + +/// +/// Represents the client registration request metadata. +/// +public class ClientRegistrationRequest +{ + /// + /// Array of redirection URI strings for use in redirect-based flows. + /// + [JsonPropertyName("redirect_uris")] + public List RedirectUris { get; set; } = new(); + + /// + /// String indicator of the requested authentication method for the token endpoint. + /// + [JsonPropertyName("token_endpoint_auth_method")] + public string? TokenEndpointAuthMethod { get; set; } + + /// + /// Array of OAuth 2.0 grant type strings that the client can use at the token endpoint. + /// + [JsonPropertyName("grant_types")] + public List? GrantTypes { get; set; } + + /// + /// Array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint. + /// + [JsonPropertyName("response_types")] + public List? ResponseTypes { get; set; } + + /// + /// Human-readable string name of the client to be presented to the end-user during authorization. + /// + [JsonPropertyName("client_name")] + public string? ClientName { get; set; } + + /// + /// URL string of a web page providing information about the client. + /// + [JsonPropertyName("client_uri")] + public string? ClientUri { get; set; } + + /// + /// URL string that references a logo for the client. + /// + [JsonPropertyName("logo_uri")] + public string? LogoUri { get; set; } + + /// + /// String containing a space-separated list of scope values that the client can use. + /// + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + /// + /// Array of strings representing ways to contact people responsible for this client. + /// + [JsonPropertyName("contacts")] + public List? Contacts { get; set; } + + /// + /// URL string that points to a human-readable terms of service document for the client. + /// + [JsonPropertyName("tos_uri")] + public string? TosUri { get; set; } + + /// + /// URL string that points to a human-readable privacy policy document. + /// + [JsonPropertyName("policy_uri")] + public string? PolicyUri { get; set; } + + /// + /// URL string referencing the client's JSON Web Key (JWK) Set document. + /// + [JsonPropertyName("jwks_uri")] + public string? JwksUri { get; set; } + + /// + /// Client's JSON Web Key Set document value. + /// + [JsonPropertyName("jwks")] + public object? Jwks { get; set; } + + /// + /// A unique identifier string assigned by the client developer or software publisher. + /// + [JsonPropertyName("software_id")] + public string? SoftwareId { get; set; } + + /// + /// A version identifier string for the client software identified by software_id. + /// + [JsonPropertyName("software_version")] + public string? SoftwareVersion { get; set; } +} diff --git a/src/ModelContextProtocol/Auth/ClientRegistrationResponse.cs b/src/ModelContextProtocol/Auth/ClientRegistrationResponse.cs new file mode 100644 index 00000000..4147d42b --- /dev/null +++ b/src/ModelContextProtocol/Auth/ClientRegistrationResponse.cs @@ -0,0 +1,34 @@ +// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\ClientRegistration.cs +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Auth; + +/// +/// Represents the client registration response. +/// +public class ClientRegistrationResponse +{ + /// + /// OAuth 2.0 client identifier string. + /// + [JsonPropertyName("client_id")] + public string ClientId { get; set; } = string.Empty; + + /// + /// OAuth 2.0 client secret string. + /// + [JsonPropertyName("client_secret")] + public string? ClientSecret { get; set; } + + /// + /// Time at which the client identifier was issued. + /// + [JsonPropertyName("client_id_issued_at")] + public long? ClientIdIssuedAt { get; set; } + + /// + /// Time at which the client secret will expire or 0 if it will not expire. + /// + [JsonPropertyName("client_secret_expires_at")] + public long? ClientSecretExpiresAt { get; set; } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/McpClientExtensions.cs b/src/ModelContextProtocol/Auth/McpClientExtensions.cs new file mode 100644 index 00000000..4d7270ad --- /dev/null +++ b/src/ModelContextProtocol/Auth/McpClientExtensions.cs @@ -0,0 +1,179 @@ +// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\McpClientExtensions.cs +using System.Net.Http.Headers; +using System.Collections.Concurrent; + +namespace ModelContextProtocol.Auth; + +/// +/// Provides extension methods for MCP clients to handle authentication. +/// +public static class McpClientExtensions +{ + // Store client configuration data in a static dictionary + private static readonly ConcurrentDictionary _clientConfigs = new(); + + /// + /// Attaches an OAuth token to the HTTP request. + /// + /// The HTTP client. + /// The OAuth token. + public static void AttachToken(this HttpClient httpClient, string token) + { + if (string.IsNullOrEmpty(token)) + { + throw new ArgumentNullException(nameof(token)); + } + + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + /// + /// Configures a client to handle authorization challenges automatically. + /// + /// The HTTP client. + /// The URI to redirect to after authentication. + /// The client ID to use for authentication, or null to register a new client. + /// The client name to use for registration. + /// The requested scopes. + /// The handler to invoke when authorization is required. + public static void ConfigureAuthorizationHandler( + this HttpClient httpClient, + Uri redirectUri, + string? clientId = null, + string? clientName = null, + IEnumerable? scopes = null, + Func>? handler = null) + { + // Store authorization parameters for the HttpClient + var config = new AuthorizationConfig + { + RedirectUri = redirectUri, + ClientId = clientId, + ClientName = clientName, + Scopes = scopes?.ToList(), + AuthorizationHandler = handler + }; + + _clientConfigs[httpClient] = config; + } + + /// + /// Gets the authorization configuration for the HTTP client. + /// + /// The HTTP client. + /// The authorization configuration, or null if not configured. + public static AuthorizationConfig? GetAuthorizationConfig(this HttpClient httpClient) + { + _clientConfigs.TryGetValue(httpClient, out var config); + return config; + } + + /// + /// Handles a 401 Unauthorized response from an MCP server. + /// + /// The HTTP client. + /// The HTTP response with the 401 status code. + /// The OAuth token response if authentication was successful. + public static async Task HandleUnauthorizedResponseAsync( + this HttpClient httpClient, + HttpResponseMessage response) + { + if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized) + { + throw new ArgumentException("The response status code must be 401 Unauthorized.", nameof(response)); + } + + // Get the WWW-Authenticate header + var wwwAuthenticateHeader = response.Headers.WwwAuthenticate.ToString(); + if (string.IsNullOrEmpty(wwwAuthenticateHeader)) + { + throw new InvalidOperationException("The response does not contain a WWW-Authenticate header."); + } + + // Get the authorization configuration + var config = httpClient.GetAuthorizationConfig(); + if (config == null) + { + throw new InvalidOperationException("The HTTP client has not been configured for authorization handling. Call ConfigureAuthorizationHandler() first."); + } + + // Create OAuthAuthenticationService + var authService = new OAuthAuthenticationService(); + + // Get resource URI + var resourceUri = response.RequestMessage?.RequestUri ?? throw new InvalidOperationException("Request URI is not available."); + + // Start the authentication flow + try + { + var tokenResponse = await authService.HandleAuthenticationAsync( + resourceUri, + wwwAuthenticateHeader, + config.RedirectUri, + config.ClientId, + config.ClientName, + config.Scopes); + + // Attach the access token to future requests + httpClient.AttachToken(tokenResponse.AccessToken); + + return tokenResponse; + } + catch (NotImplementedException ex) when (ex.Message.Contains("Authorization requires user interaction")) + { + // Extract the authorization URL from the exception message + var authUrlStart = ex.Message.IndexOf("http"); + var authUrlEnd = ex.Message.IndexOf("\n", authUrlStart); + var authUrl = ex.Message.Substring(authUrlStart, authUrlEnd - authUrlStart); + + // Check if a handler is registered + if (config.AuthorizationHandler != null) + { + // Call the handler to get the authorization code + var authCode = await config.AuthorizationHandler(new Uri(authUrl)); + + // In a real implementation, we would use the authorization code to get a token + // For now, throw an exception with instructions + throw new NotImplementedException( + "Authorization code acquired, but token exchange is not implemented. " + + "In a real implementation, this would call ExchangeAuthorizationCodeForTokenAsync."); + } + else + { + // Re-throw the original exception + throw; + } + } + } +} + +/// +/// Configuration for OAuth authorization. +/// +public class AuthorizationConfig +{ + /// + /// The URI to redirect to after authentication. + /// + public Uri RedirectUri { get; set; } = null!; + + /// + /// The client ID to use for authentication, or null to register a new client. + /// + public string? ClientId { get; set; } + + /// + /// The client name to use for registration. + /// + public string? ClientName { get; set; } + + /// + /// The requested scopes. + /// + public IEnumerable? Scopes { get; set; } + + /// + /// The handler to invoke when authorization is required. + /// + public Func>? AuthorizationHandler { get; set; } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs new file mode 100644 index 00000000..0052398a --- /dev/null +++ b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs @@ -0,0 +1,373 @@ +// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\OAuthAuthenticationService.cs +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ModelContextProtocol.Utils.Json; + +namespace ModelContextProtocol.Auth; + +/// +/// Provides functionality for OAuth authentication in MCP clients. +/// +public class OAuthAuthenticationService +{ + private static readonly HttpClient _httpClient = new(); + + /// + /// Handles the OAuth authentication flow when a 401 Unauthorized response is received. + /// + /// The URI of the resource being accessed. + /// The WWW-Authenticate header from the 401 response. + /// The URI to redirect to after authentication. + /// The client ID to use for authentication, or null to register a new client. + /// The client name to use for registration. + /// The requested scopes. + /// The OAuth token response. + public async Task HandleAuthenticationAsync( + Uri resourceUri, + string wwwAuthenticateHeader, + Uri redirectUri, + string? clientId = null, + string? clientName = null, + IEnumerable? scopes = null) + { + // Extract resource metadata URL from WWW-Authenticate header + var resourceMetadataUri = ExtractResourceMetadataUri(wwwAuthenticateHeader); + if (resourceMetadataUri == null) + { + throw new InvalidOperationException("Resource metadata URI not found in WWW-Authenticate header."); + } + + // Get resource metadata + var resourceMetadata = await GetResourceMetadataAsync(resourceMetadataUri); + + // Verify that the resource in the metadata matches the server's FQDN + VerifyResourceUri(resourceUri, resourceMetadata.Resource); + + // Get the first authorization server + if (resourceMetadata.AuthorizationServers.Count == 0) + { + throw new InvalidOperationException("No authorization servers found in resource metadata."); + } + + var authServerUri = resourceMetadata.AuthorizationServers[0]; + + // Get authorization server metadata + var authServerMetadata = await DiscoverAuthorizationServerMetadataAsync(authServerUri); + + // Register client if needed + string effectiveClientId; + string? clientSecret = null; + + if (string.IsNullOrEmpty(clientId) && authServerMetadata.RegistrationEndpoint != null) + { + var registrationResponse = await RegisterClientAsync( + authServerMetadata.RegistrationEndpoint, + redirectUri, + clientName ?? "MCP Client", + scopes); + + effectiveClientId = registrationResponse.ClientId; + clientSecret = registrationResponse.ClientSecret; + } + else if (string.IsNullOrEmpty(clientId)) + { + throw new InvalidOperationException("Client ID not provided and registration endpoint not available."); + } + else + { + // We know clientId is not null or empty at this point, but the compiler doesn't + // so we need to use the null-forgiving operator + effectiveClientId = clientId!; + } + + // Perform authorization code flow with PKCE + var tokenResponse = await PerformAuthorizationCodeFlowAsync( + authServerMetadata, + effectiveClientId, // This is now guaranteed to be non-null + clientSecret, + redirectUri, + scopes?.ToList() ?? resourceMetadata.ScopesSupported); + + return tokenResponse; + } + + private Uri? ExtractResourceMetadataUri(string wwwAuthenticateHeader) + { + if (string.IsNullOrEmpty(wwwAuthenticateHeader)) + { + return null; + } + + // Parse the WWW-Authenticate header to extract the resource_metadata parameter + if (wwwAuthenticateHeader.Contains("resource_metadata=")) + { + var resourceMetadataStart = wwwAuthenticateHeader.IndexOf("resource_metadata=") + "resource_metadata=".Length; + var resourceMetadataEnd = wwwAuthenticateHeader.IndexOf("\"", resourceMetadataStart + 1); + if (resourceMetadataEnd > resourceMetadataStart) + { + var resourceMetadataUri = wwwAuthenticateHeader.Substring(resourceMetadataStart + 1, resourceMetadataEnd - resourceMetadataStart - 1); + return new Uri(resourceMetadataUri); + } + } + + return null; + } + + private async Task GetResourceMetadataAsync(Uri resourceMetadataUri) + { + var response = await _httpClient.GetAsync(resourceMetadataUri); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var resourceMetadata = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + if (resourceMetadata == null) + { + throw new InvalidOperationException("Failed to parse resource metadata."); + } + + return resourceMetadata; + } + + private void VerifyResourceUri(Uri resourceUri, Uri metadataResourceUri) + { + // Verify that the resource in the metadata matches the server's FQDN + if (!(Uri.Compare(resourceUri, metadataResourceUri, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0)) + { + throw new InvalidOperationException($"Resource URI in metadata ({metadataResourceUri}) does not match the server URI ({resourceUri})."); + } + } + + private async Task DiscoverAuthorizationServerMetadataAsync(Uri authServerUri) + { + // Try common well-known endpoints + var openIdConfigUri = new Uri(authServerUri, ".well-known/openid-configuration"); + var oauthConfigUri = new Uri(authServerUri, ".well-known/oauth-authorization-server"); + + // Try OpenID Connect configuration endpoint first + try + { + var response = await _httpClient.GetAsync(openIdConfigUri); + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + var metadata = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + if (metadata != null) + { + return metadata; + } + } + } + catch (Exception) + { + // Try next endpoint + } + + // Try OAuth 2.0 authorization server metadata endpoint + try + { + var response = await _httpClient.GetAsync(oauthConfigUri); + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + var metadata = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + if (metadata != null) + { + return metadata; + } + } + } + catch (Exception) + { + // No more endpoints to try + } + + throw new InvalidOperationException("Could not discover authorization server metadata. Neither OpenID Connect nor OAuth 2.0 well-known endpoints returned valid metadata."); + } + + private async Task RegisterClientAsync(Uri registrationEndpoint, Uri redirectUri, string clientName, IEnumerable? scopes) + { + var request = new ClientRegistrationRequest + { + RedirectUris = new List { redirectUri.ToString() }, + ClientName = clientName, + TokenEndpointAuthMethod = "client_secret_basic", + GrantTypes = new List { "authorization_code", "refresh_token" }, + ResponseTypes = new List { "code" }, + Scope = scopes != null ? string.Join(" ", scopes) : null + }; + + var json = JsonSerializer.Serialize(request, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(registrationEndpoint, content); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(); + var registrationResponse = JsonSerializer.Deserialize(responseJson, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + if (registrationResponse == null) + { + throw new InvalidOperationException("Failed to parse client registration response."); + } + + return registrationResponse; + } + + private async Task PerformAuthorizationCodeFlowAsync( + AuthorizationServerMetadata authServerMetadata, + string clientId, + string? clientSecret, + Uri redirectUri, + IEnumerable scopes) + { + // Generate PKCE code verifier and challenge + var codeVerifier = GenerateCodeVerifier(); + var codeChallenge = GenerateCodeChallenge(codeVerifier); + + // Build authorization URL + var authorizationUrl = BuildAuthorizationUrl( + authServerMetadata.AuthorizationEndpoint, + clientId, + redirectUri, + codeChallenge, + scopes); + + // At this point, in a real application, you would redirect the user to the authorizationUrl + // and then handle the callback to redirectUri with the authorization code. + // For this implementation, we'll assume the code is obtained externally and passed to us. + + // Since we can't actually perform the browser interaction in this service, + // we'll throw with instructions + throw new NotImplementedException( + $"Authorization requires user interaction. Please direct the user to: {authorizationUrl}\n" + + $"After authorization, the user will be redirected to: {redirectUri}?code=[authorization_code]\n" + + $"You need to handle this redirect and extract the authorization code to complete the flow."); + + // In a real implementation, after getting the authorization code: + // var authorizationCode = GetAuthorizationCodeFromRedirect(); + // return await ExchangeAuthorizationCodeForTokenAsync( + // authServerMetadata.TokenEndpoint, + // clientId, + // clientSecret, + // redirectUri, + // authorizationCode, + // codeVerifier); + } + + private string GenerateCodeVerifier() + { + // Generate a cryptographically random code verifier + var bytes = new byte[64]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + + // Base64url encode the random bytes + var base64 = Convert.ToBase64String(bytes); + var base64Url = base64 + .Replace('+', '-') + .Replace('/', '_') + .Replace("=", ""); + + return base64Url; + } + + private string GenerateCodeChallenge(string codeVerifier) + { + // Create code challenge using S256 method + using var sha256 = SHA256.Create(); + var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); + + // Base64url encode the hash + var base64 = Convert.ToBase64String(challengeBytes); + var base64Url = base64 + .Replace('+', '-') + .Replace('/', '_') + .Replace("=", ""); + + return base64Url; + } + + private string BuildAuthorizationUrl( + Uri authorizationEndpoint, + string clientId, + Uri redirectUri, + string codeChallenge, + IEnumerable scopes) + { + var scopeString = string.Join(" ", scopes); + + var queryParams = new Dictionary + { + ["client_id"] = clientId, + ["response_type"] = "code", + ["redirect_uri"] = redirectUri.ToString(), + ["scope"] = scopeString, + ["code_challenge"] = codeChallenge, + ["code_challenge_method"] = "S256", + ["state"] = GenerateRandomString(16) // Used for CSRF protection + }; + + var queryString = string.Join("&", queryParams.Select(p => $"{WebUtility.UrlEncode(p.Key)}={WebUtility.UrlEncode(p.Value)}")); + return $"{authorizationEndpoint}?{queryString}"; + } + + private string GenerateRandomString(int length) + { + var bytes = new byte[length]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + return Convert.ToBase64String(bytes) + .Replace('+', '-') + .Replace('/', '_') + .Replace("=", "") + .Substring(0, length); + } + + // This method would be used in a real implementation after receiving the authorization code + private async Task ExchangeAuthorizationCodeForTokenAsync( + Uri tokenEndpoint, + string clientId, + string? clientSecret, + Uri redirectUri, + string authorizationCode, + string codeVerifier) + { + var tokenRequest = new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = authorizationCode, + ["redirect_uri"] = redirectUri.ToString(), + ["client_id"] = clientId, + ["code_verifier"] = codeVerifier + }; + + var requestContent = new FormUrlEncodedContent(tokenRequest); + + HttpResponseMessage response; + if (!string.IsNullOrEmpty(clientSecret)) + { + // Add client authentication if secret is available + var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue); + response = await _httpClient.PostAsync(tokenEndpoint, requestContent); + _httpClient.DefaultRequestHeaders.Authorization = null; + } + else + { + response = await _httpClient.PostAsync(tokenEndpoint, requestContent); + } + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var tokenResponse = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + if (tokenResponse == null) + { + throw new InvalidOperationException("Failed to parse token response."); + } + + return tokenResponse; + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs b/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs new file mode 100644 index 00000000..fd095dd1 --- /dev/null +++ b/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs @@ -0,0 +1,143 @@ +// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\OAuthDelegatingHandler.cs +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace ModelContextProtocol.Auth; + +/// +/// A delegating handler that automatically handles OAuth authentication for MCP clients. +/// +public class OAuthDelegatingHandler : DelegatingHandler +{ + private readonly Uri _redirectUri; + private readonly string? _clientId; + private readonly string? _clientName; + private readonly IEnumerable? _scopes; + private readonly Func>? _authorizationHandler; + private OAuthTokenResponse? _tokenResponse; + + /// + /// Initializes a new instance of the class. + /// + /// The URI to redirect to after authentication. + /// The client ID to use for authentication, or null to register a new client. + /// The client name to use for registration. + /// The requested scopes. + /// A handler to invoke when authorization is required. + public OAuthDelegatingHandler( + Uri redirectUri, + string? clientId = null, + string? clientName = null, + IEnumerable? scopes = null, + Func>? authorizationHandler = null) + { + _redirectUri = redirectUri; + _clientId = clientId; + _clientName = clientName; + _scopes = scopes; + _authorizationHandler = authorizationHandler; + } + + /// + /// Initializes a new instance of the class with an inner handler. + /// + /// The inner handler which processes the HTTP response messages. + /// The URI to redirect to after authentication. + /// The client ID to use for authentication, or null to register a new client. + /// The client name to use for registration. + /// The requested scopes. + /// A handler to invoke when authorization is required. + public OAuthDelegatingHandler( + HttpMessageHandler innerHandler, + Uri redirectUri, + string? clientId = null, + string? clientName = null, + IEnumerable? scopes = null, + Func>? authorizationHandler = null) + : base(innerHandler) + { + _redirectUri = redirectUri; + _clientId = clientId; + _clientName = clientName; + _scopes = scopes; + _authorizationHandler = authorizationHandler; + } + + /// + /// Manually set an OAuth token to use for subsequent requests. + /// + /// The OAuth token response. + public void SetToken(OAuthTokenResponse tokenResponse) + { + _tokenResponse = tokenResponse; + } + + /// + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + // If we have a token, attach it to the request + if (_tokenResponse != null) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _tokenResponse.AccessToken); + } + + // Send the request + var response = await base.SendAsync(request, cancellationToken); + + // If the response is 401 Unauthorized, try to authenticate + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + // Create a temporary HttpClient to handle the authentication + // We need to use a new client to avoid infinite recursion + using var httpClient = new HttpClient(); + httpClient.ConfigureAuthorizationHandler( + _redirectUri, + _clientId, + _clientName, + _scopes, + _authorizationHandler); + + try + { + // Handle the 401 response + var authResponse = await httpClient.HandleUnauthorizedResponseAsync(response); + _tokenResponse = authResponse; // Now using a non-nullable intermediate variable + + // If we have a token, retry the original request with the token + // Create a new request (the original request has already been sent) + var newRequest = new HttpRequestMessage + { + Method = request.Method, + RequestUri = request.RequestUri, + Content = request.Content, + Headers = { + Authorization = new AuthenticationHeaderValue("Bearer", _tokenResponse.AccessToken) + } + }; + + // Copy other headers + foreach (var header in request.Headers) + { + if (header.Key != "Authorization") + { + newRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + // Send the request again + return await base.SendAsync(newRequest, cancellationToken); + } + catch (Exception) + { + // If authentication fails, return the original 401 response + } + } + + return response; + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs b/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs new file mode 100644 index 00000000..4cac764d --- /dev/null +++ b/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs @@ -0,0 +1,46 @@ +// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\OAuthTokenResponse.cs +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Auth; + +/// +/// Represents an OAuth token response. +/// +public class OAuthTokenResponse +{ + /// + /// The access token issued by the authorization server. + /// + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + + /// + /// The type of token issued. + /// + [JsonPropertyName("token_type")] + public string TokenType { get; set; } = string.Empty; + + /// + /// The lifetime in seconds of the access token. + /// + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } + + /// + /// The refresh token used to obtain new access tokens using the same authorization grant. + /// + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + + /// + /// The scopes associated with the access token. + /// + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + /// + /// An ID token as a JWT (JSON Web Token). + /// + [JsonPropertyName("id_token")] + public string? IdToken { get; set; } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/ProtectedResourceMetadata.cs b/src/ModelContextProtocol/Auth/ProtectedResourceMetadata.cs new file mode 100644 index 00000000..ed92a05c --- /dev/null +++ b/src/ModelContextProtocol/Auth/ProtectedResourceMetadata.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Auth; + +/// +/// Represents the resource metadata for OAuth authorization. +/// +public class ProtectedResourceMetadata +{ + /// + /// The resource URI. + /// + [JsonPropertyName("resource")] + public Uri Resource { get; set; } = null!; + + /// + /// The list of authorization server URIs. + /// + [JsonPropertyName("authorization_servers")] + public List AuthorizationServers { get; set; } = new(); + + /// + /// The supported bearer token methods. + /// + [JsonPropertyName("bearer_methods_supported")] + public List BearerMethodsSupported { get; set; } = new(); + + /// + /// The supported scopes. + /// + [JsonPropertyName("scopes_supported")] + public List ScopesSupported { get; set; } = new(); + + /// + /// The URI to the resource documentation. + /// + [JsonPropertyName("resource_documentation")] + public Uri? ResourceDocumentation { get; set; } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs index b759ba97..66b42b95 100644 --- a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs +++ b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.AI; +using ModelContextProtocol.Auth; using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Protocol.Types; using System.Diagnostics.CodeAnalysis; @@ -123,6 +124,12 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(UnsubscribeRequestParams))] [JsonSerializable(typeof(IReadOnlyDictionary))] + [JsonSerializable(typeof(AuthorizationServerMetadata))] + [JsonSerializable(typeof(ClientRegistrationRequest))] + [JsonSerializable(typeof(ClientRegistrationResponse))] + [JsonSerializable(typeof(OAuthTokenResponse))] + [JsonSerializable(typeof(ProtectedResourceMetadata))] + [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; From ac14bb2561aa848cc89ac5cb08af9073dc4d3ccd Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 1 May 2025 21:49:48 -0700 Subject: [PATCH 002/128] Fix build issues --- samples/AspNetCoreSseServer/Program.cs | 1 + .../Auth/McpAuthorizationExtensions.cs | 65 ------------------- .../Auth/ResourceMetadataEndpointHandler.cs | 24 +++++++ .../Auth/ResourceMetadataService.cs | 52 +++++++++++++++ .../McpEndpointRouteBuilderExtensions.cs | 17 +++-- .../MapMcpSseTests.cs | 3 +- .../MapMcpStreamableHttpTests.cs | 3 +- .../MapMcpTests.cs | 3 +- .../Program.cs | 1 + 9 files changed, 92 insertions(+), 77 deletions(-) create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataService.cs diff --git a/samples/AspNetCoreSseServer/Program.cs b/samples/AspNetCoreSseServer/Program.cs index 41f98ee5..aade7051 100644 --- a/samples/AspNetCoreSseServer/Program.cs +++ b/samples/AspNetCoreSseServer/Program.cs @@ -2,6 +2,7 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Trace; using OpenTelemetry; +using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs index 77d8bd7a..38d41949 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -1,4 +1,3 @@ -// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol.AspNetCore\Auth\McpAuthorizationExtensions.cs using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -31,68 +30,4 @@ public static AuthenticationBuilder AddMcpAuthorization( "MCP Authentication", configureOptions ?? (options => { })); } - - /// - /// Maps the resource metadata endpoint for MCP OAuth authorization. - /// - /// The endpoint route builder. - /// The route pattern. - /// An action to configure the resource metadata. - /// An endpoint convention builder for further configuration. - public static IEndpointConventionBuilder MapMcpResourceMetadata( - this IEndpointRouteBuilder endpoints, - string pattern = "/.well-known/oauth-protected-resource", - Action? configure = null) - { - var metadataService = endpoints.ServiceProvider.GetRequiredService(); - - if (configure != null) - { - metadataService.ConfigureMetadata(configure); - } - - return endpoints.MapGet(pattern, async (HttpContext context) => - { - var metadata = metadataService.GetMetadata(); - - // Set default resource if not set - if (metadata.Resource == null) - { - var request = context.Request; - var hostString = request.Host.Value; - var scheme = request.Scheme; - metadata.Resource = new Uri($"{scheme}://{hostString}"); - } - - return Results.Json(metadata); - }) - .AllowAnonymous() - .WithDisplayName("MCP Resource Metadata"); - } } - -/// -/// Service for managing MCP resource metadata. -/// -public class ResourceMetadataService -{ - private readonly ProtectedResourceMetadata _metadata = new(); - - /// - /// Configures the resource metadata. - /// - /// Configuration action. - public void ConfigureMetadata(Action configure) - { - configure(_metadata); - } - - /// - /// Gets the resource metadata. - /// - /// The resource metadata. - public ProtectedResourceMetadata GetMetadata() - { - return _metadata; - } -} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs new file mode 100644 index 00000000..961bf0e7 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.AspNetCore.Auth; + +namespace ModelContextProtocol.AspNetCore; + +public static partial class McpEndpointRouteBuilderExtensions +{ + // This class handles the resource metadata endpoint in an AOT-compatible way + private sealed class ResourceMetadataEndpointHandler + { + private readonly ResourceMetadataService _resourceMetadataService; + + public ResourceMetadataEndpointHandler(ResourceMetadataService resourceMetadataService) + { + _resourceMetadataService = resourceMetadataService; + } + + public Task HandleRequest(HttpContext httpContext) + { + var result = _resourceMetadataService.HandleResourceMetadataRequest(httpContext); + return result.ExecuteAsync(httpContext); + } + } +} diff --git a/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataService.cs b/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataService.cs new file mode 100644 index 00000000..aeb967da --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataService.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.Auth; +using ModelContextProtocol.Utils.Json; + +namespace ModelContextProtocol.AspNetCore.Auth; + +/// +/// Service for managing MCP resource metadata. +/// +public class ResourceMetadataService +{ + private readonly ProtectedResourceMetadata _metadata = new(); + + /// + /// Configures the resource metadata. + /// + /// Configuration action. + public void ConfigureMetadata(Action configure) + { + configure(_metadata); + } + + /// + /// Gets the resource metadata. + /// + /// The resource metadata. + public ProtectedResourceMetadata GetMetadata() + { + return _metadata; + } + + /// + /// Handles the resource metadata request. + /// + /// The HTTP context. + /// An IResult containing the resource metadata. + public IResult HandleResourceMetadataRequest(HttpContext context) + { + var metadata = GetMetadata(); + + // Set default resource if not set + if (metadata.Resource == null) + { + var request = context.Request; + var hostString = request.Host.Value; + var scheme = request.Scheme; + metadata.Resource = new Uri($"{scheme}://{hostString}"); + } + + return Results.Json(metadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))); + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index 2286dabd..6cd7186b 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -1,19 +1,19 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.AspNetCore; using ModelContextProtocol.AspNetCore.Auth; using ModelContextProtocol.Auth; using ModelContextProtocol.Protocol.Messages; using System.Diagnostics.CodeAnalysis; -namespace Microsoft.AspNetCore.Builder; +namespace ModelContextProtocol.AspNetCore; /// /// Provides extension methods for to add MCP endpoints. /// -public static class McpEndpointRouteBuilderExtensions +public static partial class McpEndpointRouteBuilderExtensions { /// /// Sets up endpoints for handling MCP Streamable HTTP transport. @@ -59,8 +59,13 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo // Authorization is configured, so automatically map the OAuth protected resource endpoint var resourceMetadataService = endpoints.ServiceProvider.GetRequiredService(); - // Map the OAuth protected resource endpoint - endpoints.MapMcpResourceMetadata("/.well-known/oauth-protected-resource"); + // Use an AOT-compatible approach with a statically compiled RequestDelegate + var handler = new ResourceMetadataEndpointHandler(resourceMetadataService); + + sseGroup.MapGet("/.well-known/oauth-protected-resource", handler.HandleRequest) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["application/json"])) + .AllowAnonymous() + .WithDisplayName("MCP Resource Metadata"); // Apply authorization to MCP endpoints mcpGroup.RequireAuthorization("McpAuth"); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs index d385623a..fef2f3c4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; namespace ModelContextProtocol.AspNetCore.Tests; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 30632a8e..dfbf26ae 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; namespace ModelContextProtocol.AspNetCore.Tests; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index 70b028e2..47e9344e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index 72a271cf..2e18d30b 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Connections; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Server; using ModelContextProtocol.Utils.Json; From e8d9a23185fecb01e635c2bb4f083d539ad3173c Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 1 May 2025 21:52:23 -0700 Subject: [PATCH 003/128] Cleanup --- .../Auth/McpAuthenticationHandler.cs | 2 -- .../Auth/McpAuthorizationExtensions.cs | 5 ----- src/ModelContextProtocol/Auth/AuthorizationHandlers.cs | 1 - src/ModelContextProtocol/Auth/AuthorizationServerMetadata.cs | 1 - src/ModelContextProtocol/Auth/ClientRegistrationRequest.cs | 1 - src/ModelContextProtocol/Auth/ClientRegistrationResponse.cs | 1 - src/ModelContextProtocol/Auth/McpClientExtensions.cs | 1 - src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs | 1 - src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs | 4 ---- src/ModelContextProtocol/Auth/OAuthTokenResponse.cs | 1 - 10 files changed, 18 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index e78d8479..42bf0f90 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -1,6 +1,4 @@ -// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol.AspNetCore\Auth\McpAuthenticationHandler.cs using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Text.Encodings.Web; diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs index 38d41949..729a8ea1 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -1,10 +1,5 @@ using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using ModelContextProtocol.Auth; namespace ModelContextProtocol.AspNetCore.Auth; diff --git a/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs b/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs index d937ef72..242d47aa 100644 --- a/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs +++ b/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs @@ -1,4 +1,3 @@ -// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\AuthorizationHandlers.cs using System.Security.Cryptography; using System.Text; diff --git a/src/ModelContextProtocol/Auth/AuthorizationServerMetadata.cs b/src/ModelContextProtocol/Auth/AuthorizationServerMetadata.cs index 9d1228f6..ff204688 100644 --- a/src/ModelContextProtocol/Auth/AuthorizationServerMetadata.cs +++ b/src/ModelContextProtocol/Auth/AuthorizationServerMetadata.cs @@ -1,4 +1,3 @@ -// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\AuthorizationServerMetadata.cs using System.Text.Json.Serialization; namespace ModelContextProtocol.Auth; diff --git a/src/ModelContextProtocol/Auth/ClientRegistrationRequest.cs b/src/ModelContextProtocol/Auth/ClientRegistrationRequest.cs index 96fc249d..f73e934c 100644 --- a/src/ModelContextProtocol/Auth/ClientRegistrationRequest.cs +++ b/src/ModelContextProtocol/Auth/ClientRegistrationRequest.cs @@ -1,4 +1,3 @@ -// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\ClientRegistration.cs using System.Text.Json.Serialization; namespace ModelContextProtocol.Auth; diff --git a/src/ModelContextProtocol/Auth/ClientRegistrationResponse.cs b/src/ModelContextProtocol/Auth/ClientRegistrationResponse.cs index 4147d42b..dba40c3a 100644 --- a/src/ModelContextProtocol/Auth/ClientRegistrationResponse.cs +++ b/src/ModelContextProtocol/Auth/ClientRegistrationResponse.cs @@ -1,4 +1,3 @@ -// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\ClientRegistration.cs using System.Text.Json.Serialization; namespace ModelContextProtocol.Auth; diff --git a/src/ModelContextProtocol/Auth/McpClientExtensions.cs b/src/ModelContextProtocol/Auth/McpClientExtensions.cs index 4d7270ad..95d1bc5f 100644 --- a/src/ModelContextProtocol/Auth/McpClientExtensions.cs +++ b/src/ModelContextProtocol/Auth/McpClientExtensions.cs @@ -1,4 +1,3 @@ -// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\McpClientExtensions.cs using System.Net.Http.Headers; using System.Collections.Concurrent; diff --git a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs index 0052398a..41dd2dd1 100644 --- a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs +++ b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs @@ -1,4 +1,3 @@ -// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\OAuthAuthenticationService.cs using System.Net; using System.Net.Http.Headers; using System.Security.Cryptography; diff --git a/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs b/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs index fd095dd1..75d2e291 100644 --- a/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs +++ b/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs @@ -1,9 +1,5 @@ -// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\OAuthDelegatingHandler.cs using System.Net; -using System.Net.Http; using System.Net.Http.Headers; -using System.Threading; -using System.Threading.Tasks; namespace ModelContextProtocol.Auth; diff --git a/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs b/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs index 4cac764d..426b737b 100644 --- a/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs +++ b/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs @@ -1,4 +1,3 @@ -// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\src\ModelContextProtocol\Auth\OAuthTokenResponse.cs using System.Text.Json.Serialization; namespace ModelContextProtocol.Auth; From 4b3f9f77d9635e348a94c00b769d7a8d36f86430 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 1 May 2025 22:08:37 -0700 Subject: [PATCH 004/128] Cleanup --- ModelContextProtocol.sln | 14 ++ samples/SecureWeatherServer/Program.cs | 233 +----------------- .../Properties/launchSettings.json | 12 + .../Tools/HttpClientExt.cs | 13 + .../SecureWeatherServer/Tools/WeatherTools.cs | 60 +++++ 5 files changed, 111 insertions(+), 221 deletions(-) create mode 100644 samples/SecureWeatherServer/Properties/launchSettings.json create mode 100644 samples/SecureWeatherServer/Tools/HttpClientExt.cs create mode 100644 samples/SecureWeatherServer/Tools/WeatherTools.cs diff --git a/ModelContextProtocol.sln b/ModelContextProtocol.sln index 0e4fd721..0a72e940 100644 --- a/ModelContextProtocol.sln +++ b/ModelContextProtocol.sln @@ -56,6 +56,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.AspNet EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.AspNetCore.Tests", "tests\ModelContextProtocol.AspNetCore.Tests\ModelContextProtocol.AspNetCore.Tests.csproj", "{85557BA6-3D29-4C95-A646-2A972B1C2F25}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureWeatherClient", "samples\SecureWeatherClient\SecureWeatherClient.csproj", "{CF41BB82-4E3E-5E86-BCB6-0DF3A1B48CF2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureWeatherServer", "samples\SecureWeatherServer\SecureWeatherServer.csproj", "{80944644-54DC-2AFF-C60E-9885AD81E509}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -110,6 +114,14 @@ Global {85557BA6-3D29-4C95-A646-2A972B1C2F25}.Debug|Any CPU.Build.0 = Debug|Any CPU {85557BA6-3D29-4C95-A646-2A972B1C2F25}.Release|Any CPU.ActiveCfg = Release|Any CPU {85557BA6-3D29-4C95-A646-2A972B1C2F25}.Release|Any CPU.Build.0 = Release|Any CPU + {CF41BB82-4E3E-5E86-BCB6-0DF3A1B48CF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF41BB82-4E3E-5E86-BCB6-0DF3A1B48CF2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF41BB82-4E3E-5E86-BCB6-0DF3A1B48CF2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF41BB82-4E3E-5E86-BCB6-0DF3A1B48CF2}.Release|Any CPU.Build.0 = Release|Any CPU + {80944644-54DC-2AFF-C60E-9885AD81E509}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80944644-54DC-2AFF-C60E-9885AD81E509}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80944644-54DC-2AFF-C60E-9885AD81E509}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80944644-54DC-2AFF-C60E-9885AD81E509}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -128,6 +140,8 @@ Global {17B8453F-AB72-99C5-E5EA-D0B065A6AE65} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {37B6A5E0-9995-497D-8B43-3BC6870CC716} = {A2F1F52A-9107-4BF8-8C3F-2F6670E7D0AD} {85557BA6-3D29-4C95-A646-2A972B1C2F25} = {2A77AF5C-138A-4EBB-9A13-9205DCD67928} + {CF41BB82-4E3E-5E86-BCB6-0DF3A1B48CF2} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {80944644-54DC-2AFF-C60E-9885AD81E509} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {384A3888-751F-4D75-9AE5-587330582D89} diff --git a/samples/SecureWeatherServer/Program.cs b/samples/SecureWeatherServer/Program.cs index a657707d..118b861c 100644 --- a/samples/SecureWeatherServer/Program.cs +++ b/samples/SecureWeatherServer/Program.cs @@ -1,242 +1,33 @@ -// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\samples\SecureWeatherServer\Program.cs -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.AspNetCore.Auth; -using ModelContextProtocol.Protocol.Messages; -using ModelContextProtocol.Server; +using SecureWeatherServer.Tools; +using System.Net.Http.Headers; var builder = WebApplication.CreateBuilder(args); -// Add MCP server with OAuth authorization builder.Services.AddMcpServer() .WithHttpTransport() - .WithOAuthAuthorization(metadata => + .WithTools() + .WithAuthorization(metadata => { - // Configure the resource metadata metadata.AuthorizationServers.Add(new Uri("https://auth.example.com")); - metadata.ScopesSupported.AddRange(new[] { "weather.read", "weather.write" }); + metadata.ScopesSupported.AddRange(["weather.read", "weather.write"]); metadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); }); -// Build the app +builder.Services.AddSingleton(_ => +{ + var client = new HttpClient() { BaseAddress = new Uri("https://api.weather.gov") }; + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0")); + return client; +}); + var app = builder.Build(); -// Enable CORS for development app.UseCors(policy => policy .AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader()); -// Configure the HTTP request pipeline app.UseAuthentication(); app.UseAuthorization(); -// Map MCP endpoints with authorization -app.MapMcpWithAuthorization(); - -// Define weather tool -var weatherTool = new McpTool("get_weather", "Get the current weather for a location") - .WithParameter("location", "The location to get the weather for", typeof(string), required: true); - -// Define weather server logic -app.UseMiddleware(options => -{ - options.RegisterTool(weatherTool, async (McpToolInvokeParameters parameters, CancellationToken ct) => - { - if (!parameters.TryGetParameterValue("location", out var location)) - { - return McpToolResult.Error("Location parameter is required"); - } - - // In a real implementation, you would get the weather for the location - // For this example, we'll just return a random weather - var weather = GetRandomWeather(location); - - return McpToolResult.Success(new - { - location, - temperature = weather.Temperature, - conditions = weather.Conditions, - humidity = weather.Humidity, - windSpeed = weather.WindSpeed - }); - }); -}); - -// Run the app app.Run(); - -// Helper method to generate random weather -(double Temperature, string Conditions, int Humidity, double WindSpeed) GetRandomWeather(string location) -{ - var random = new Random(); - var conditions = new[] { "Sunny", "Cloudy", "Rainy", "Snowy", "Foggy", "Windy" }; - - return ( - Temperature: Math.Round(random.NextDouble() * 40 - 10, 1), // -10 to 30 degrees - Conditions: conditions[random.Next(conditions.Length)], - Humidity: random.Next(30, 95), - WindSpeed: Math.Round(random.NextDouble() * 30, 1) - ); -} - -// Middleware to handle server invocations -public class ServerInvokeMiddleware -{ - private readonly RequestDelegate _next; - private readonly McpServerInvokeOptions _options; - - public ServerInvokeMiddleware(RequestDelegate next, McpServerInvokeOptions options) - { - _next = next; - _options = options; - } - - public ServerInvokeMiddleware(RequestDelegate next, Action configureOptions) - : this(next, new McpServerInvokeOptions(configureOptions)) - { - } - - public async Task InvokeAsync(HttpContext context) - { - // Set up the MCP server with the registered tools - if (context.Features.Get() is McpServer server) - { - foreach (var registration in _options.ToolRegistrations) - { - server.RegisterToolHandler(registration.Tool.Definition, registration.Handler); - } - } - - await _next(context); - } -} - -// Helper classes for tool registration -public class McpServerInvokeOptions -{ - public List ToolRegistrations { get; } = new(); - - public McpServerInvokeOptions() { } - - public McpServerInvokeOptions(Action configure) - { - configure(this); - } - - public void RegisterTool(McpTool tool, Func> handler) - { - ToolRegistrations.Add(new ToolRegistration(tool, handler)); - } -} - -public class ToolRegistration -{ - public McpTool Tool { get; } - public Func> Handler { get; } - - public ToolRegistration( - McpTool tool, - Func> handler) - { - Tool = tool; - Handler = handler; - } -} - -// Helper class to simplify tool registration and parameter handling -public class McpTool -{ - public ToolDefinition Definition { get; } - - public McpTool(string name, string description) - { - Definition = new ToolDefinition - { - Name = name, - Description = description, - Parameters = new ToolParameterDefinition - { - Properties = {}, - Required = new List() - } - }; - } - - public McpTool WithParameter(string name, string description, Type type, bool required = false) - { - Definition.Parameters.Properties[name] = new ToolPropertyDefinition - { - Description = description, - Type = GetJsonSchemaType(type) - }; - - if (required) - { - Definition.Parameters.Required.Add(name); - } - - return this; - } - - private static string GetJsonSchemaType(Type type) - { - return type.Name.ToLowerInvariant() switch - { - "string" => "string", - "int32" or "int64" or "int" or "long" or "double" or "float" or "decimal" => "number", - "boolean" => "boolean", - _ => "object" - }; - } -} - -// Helper class for the tool invocation parameters -public class McpToolInvokeParameters -{ - private readonly Dictionary _parameters; - - public McpToolInvokeParameters(Dictionary parameters) - { - _parameters = parameters; - } - - public bool TryGetParameterValue(string name, out T value) - { - if (_parameters.TryGetValue(name, out var objValue) && objValue is T typedValue) - { - value = typedValue; - return true; - } - - value = default!; - return false; - } - - public T? GetParameterValue(string name) - { - if (_parameters.TryGetValue(name, out var value) && value is T typedValue) - { - return typedValue; - } - - return default; - } -} - -// Helper class for the tool result -public class McpToolResult -{ - public object? Result { get; } - public string? Error { get; } - public bool IsError => Error != null; - - private McpToolResult(object? result, string? error) - { - Result = result; - Error = error; - } - - public static McpToolResult Success(object? result = null) => new McpToolResult(result, null); - public static McpToolResult Error(string error) => new McpToolResult(null, error); -} \ No newline at end of file diff --git a/samples/SecureWeatherServer/Properties/launchSettings.json b/samples/SecureWeatherServer/Properties/launchSettings.json new file mode 100644 index 00000000..f190756c --- /dev/null +++ b/samples/SecureWeatherServer/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "SecureWeatherServer": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:55598;http://localhost:55599" + } + } +} \ No newline at end of file diff --git a/samples/SecureWeatherServer/Tools/HttpClientExt.cs b/samples/SecureWeatherServer/Tools/HttpClientExt.cs new file mode 100644 index 00000000..f7b2b549 --- /dev/null +++ b/samples/SecureWeatherServer/Tools/HttpClientExt.cs @@ -0,0 +1,13 @@ +using System.Text.Json; + +namespace ModelContextProtocol; + +internal static class HttpClientExt +{ + public static async Task ReadJsonDocumentAsync(this HttpClient client, string requestUri) + { + using var response = await client.GetAsync(requestUri); + response.EnsureSuccessStatusCode(); + return await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + } +} \ No newline at end of file diff --git a/samples/SecureWeatherServer/Tools/WeatherTools.cs b/samples/SecureWeatherServer/Tools/WeatherTools.cs new file mode 100644 index 00000000..ad7a95b8 --- /dev/null +++ b/samples/SecureWeatherServer/Tools/WeatherTools.cs @@ -0,0 +1,60 @@ +using ModelContextProtocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; + +namespace SecureWeatherServer.Tools; + +[McpServerToolType] +public sealed class WeatherTools +{ + [McpServerTool, Description("Get weather alerts for a US state.")] + public static async Task GetAlerts( + HttpClient client, + [Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state) + { + 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 static async Task GetForecast( + HttpClient client, + [Description("Latitude of the location.")] double latitude, + [Description("Longitude of the location.")] double longitude) + { + 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()} + """)); + } +} From 8c790dbc0c3a488c82540ee20657282c57fbd5d6 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 1 May 2025 22:51:56 -0700 Subject: [PATCH 005/128] Tweaks to logic --- samples/SecureWeatherClient/Program.cs | 90 ++++-------- samples/SecureWeatherServer/Program.cs | 137 +++++++++++++++--- .../SecureWeatherServer.csproj | 2 +- .../Auth/AuthorizationCodeOptions.cs | 47 ++++++ .../Auth/AuthorizationConfig.cs | 32 ++++ .../Auth/AuthorizationHandlers.cs | 46 ------ .../Auth/McpClientExtensions.cs | 33 +---- .../Auth/OAuthAuthenticationService.cs | 8 +- .../Auth/OAuthDelegatingHandler.cs | 4 +- .../Auth/OAuthTokenResponse.cs | 2 +- .../Utils/Json/McpJsonUtilities.cs | 2 +- 11 files changed, 239 insertions(+), 164 deletions(-) create mode 100644 src/ModelContextProtocol/Auth/AuthorizationCodeOptions.cs create mode 100644 src/ModelContextProtocol/Auth/AuthorizationConfig.cs diff --git a/samples/SecureWeatherClient/Program.cs b/samples/SecureWeatherClient/Program.cs index 78955f97..3f761588 100644 --- a/samples/SecureWeatherClient/Program.cs +++ b/samples/SecureWeatherClient/Program.cs @@ -10,7 +10,7 @@ namespace SecureWeatherClient; class Program { // The URI for our OAuth redirect - in a real app, this would be a registered URI or a local server - private static readonly Uri RedirectUri = new("http://localhost:8888/oauth-callback"); + private static readonly Uri RedirectUri = new("http://localhost:1170/oauth-callback"); static async Task Main(string[] args) { @@ -20,14 +20,19 @@ static async Task Main(string[] args) // Create an HTTP client with OAuth handling var oauthHandler = new OAuthDelegatingHandler( + clientId: "04f79824-ab56-4511-a7cb-d7deaea92dc0", redirectUri: RedirectUri, - clientName: "SecureWeatherClient", - scopes: new[] { "weather.read" }, - authorizationHandler: HandleAuthorizationRequestAsync); + clientName: "SecureWeatherClient", + scopes: ["weather.read"], + authorizationHandler: HandleAuthorizationRequestAsync) + { + // The OAuth handler needs an inner handler + InnerHandler = new HttpClientHandler() + }; var httpClient = new HttpClient(oauthHandler); - var serverUrl = "http://localhost:5000"; // Default server URL - + var serverUrl = "http://localhost:55598/sse"; // Default server URL + // Allow the user to specify a different server URL Console.WriteLine($"Server URL (press Enter for default: {serverUrl}):"); var userInput = Console.ReadLine(); @@ -35,17 +40,27 @@ static async Task Main(string[] args) { serverUrl = userInput; } - + Console.WriteLine(); Console.WriteLine($"Connecting to weather server at {serverUrl}..."); - - // Create an MCP client with the server URL - var client = new McpClient(new Uri(serverUrl), httpClient); try { + // Create SseClientTransportOptions with the server URL + var transportOptions = new SseClientTransportOptions + { + Endpoint = new Uri(serverUrl), + Name = "Secure Weather Client" + }; + + // Create SseClientTransport with our authenticated HTTP client + var transport = new SseClientTransport(transportOptions, httpClient); + + // Create an MCP client using the factory method with our transport + var client = await McpClientFactory.CreateAsync(transport); + // Get the list of available tools - var tools = await client.GetToolsAsync(); + var tools = await client.ListToolsAsync(); if (tools.Count == 0) { Console.WriteLine("No tools available on the server."); @@ -54,55 +69,14 @@ static async Task Main(string[] args) Console.WriteLine($"Found {tools.Count} tools on the server."); Console.WriteLine(); - - // Find the weather tool - var weatherTool = tools.FirstOrDefault(t => t.Name == "get_weather"); - if (weatherTool == null) - { - Console.WriteLine("The server does not provide a weather tool."); - return; - } - - // Get the weather for different locations - string[] locations = { "New York", "London", "Tokyo", "Sydney", "Moscow" }; - - foreach (var location in locations) - { - try - { - Console.WriteLine($"Getting weather for {location}..."); - var result = await client.InvokeToolAsync(weatherTool.Name, new Dictionary - { - ["location"] = location - }); - - if (result.TryGetValue("temperature", out var temperature) && - result.TryGetValue("conditions", out var conditions) && - result.TryGetValue("humidity", out var humidity) && - result.TryGetValue("windSpeed", out var windSpeed)) - { - Console.WriteLine($"Weather in {location}:"); - Console.WriteLine($" Temperature: {temperature}°C"); - Console.WriteLine($" Conditions: {conditions}"); - Console.WriteLine($" Humidity: {humidity}%"); - Console.WriteLine($" Wind speed: {windSpeed} km/h"); - } - else - { - Console.WriteLine($"Invalid response format for {location}"); - } - } - catch (Exception ex) - { - Console.WriteLine($"Error getting weather for {location}: {ex.Message}"); - } - - Console.WriteLine(); - } } catch (Exception ex) { Console.WriteLine($"Error: {ex.Message}"); + if (ex.InnerException != null) + { + Console.WriteLine($"Inner error: {ex.InnerException.Message}"); + } } Console.WriteLine("Press any key to exit..."); @@ -124,13 +98,13 @@ private static Task HandleAuthorizationRequestAsync(Uri authorizationUri Console.WriteLine(); Console.WriteLine("After authentication, you will be redirected to a page with a code."); Console.WriteLine("Please enter the code parameter from the URL:"); - + var authorizationCode = Console.ReadLine(); if (string.IsNullOrWhiteSpace(authorizationCode)) { throw new InvalidOperationException("Authorization code is required."); } - + return Task.FromResult(authorizationCode); } } \ No newline at end of file diff --git a/samples/SecureWeatherServer/Program.cs b/samples/SecureWeatherServer/Program.cs index 118b861c..efd2d743 100644 --- a/samples/SecureWeatherServer/Program.cs +++ b/samples/SecureWeatherServer/Program.cs @@ -1,33 +1,132 @@ -using SecureWeatherServer.Tools; -using System.Net.Http.Headers; +using ModelContextProtocol.AspNetCore; +using ModelContextProtocol.Protocol.Types; +using Microsoft.AspNetCore.Authentication.JwtBearer; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddMcpServer() - .WithHttpTransport() - .WithTools() - .WithAuthorization(metadata => +// Configure MCP Server +builder.Services.AddMcpServer(options => +{ + options.ServerInstructions = "This is an MCP server with OAuth authorization enabled."; + + // Configure regular server capabilities like tools, prompts, resources + options.Capabilities = new() + { + Tools = new() + { + // Simple Echo tool + CallToolHandler = (request, cancellationToken) => + { + if (request.Params?.Name == "echo") + { + if (request.Params.Arguments?.TryGetValue("message", out var message) is not true) + { + throw new Exception("It happens."); + } + + return new ValueTask(new CallToolResponse() + { + Content = [new Content() { Text = $"Echo: {message}", Type = "text" }] + }); + } + + // Protected tool that requires authorization + if (request.Params?.Name == "protected-data") + { + // This tool will only be accessible to authenticated clients + return new ValueTask(new CallToolResponse() + { + Content = [new Content() { Text = "This is protected data that only authorized clients can access" }] + }); + } + + throw new Exception("It happens."); + }, + + ListToolsHandler = async (_, _) => new() + { + Tools = + [ + new() + { + Name = "echo", + Description = "Echoes back the message you send" + }, + new() + { + Name = "protected-data", + Description = "Returns protected data that requires authorization" + } + ] + } + } + }; +}) +.WithHttpTransport() +.WithAuthorization(metadata => +{ + // Configure the OAuth metadata for this server + metadata.AuthorizationServers.Add(new Uri("https://auth.example.com")); + + // Define the scopes this server supports + metadata.ScopesSupported.AddRange(["weather.read", "weather.write"]); + + // Add optional documentation + metadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); +}); + +// Configure authentication +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => { - metadata.AuthorizationServers.Add(new Uri("https://auth.example.com")); - metadata.ScopesSupported.AddRange(["weather.read", "weather.write"]); - metadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); + // In a real app, you would configure proper JWT validation + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + // Simple demo authentication - in a real app, use proper JWT validation + var token = context.Request.Headers.Authorization.ToString().Replace("Bearer ", ""); + if (token == "valid_token") + { + // For demo purposes, simulate successful auth with a valid token + context.Success(); + } + return Task.CompletedTask; + } + }; }); -builder.Services.AddSingleton(_ => +// Add authorization policy for MCP +builder.Services.AddAuthorization(options => { - var client = new HttpClient() { BaseAddress = new Uri("https://api.weather.gov") }; - client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0")); - return client; + options.AddPolicy("McpAuth", policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireClaim("scope", "weather.read"); + }); }); var app = builder.Build(); -app.UseCors(policy => policy - .AllowAnyOrigin() - .AllowAnyMethod() - .AllowAnyHeader()); - +// Set up the middleware pipeline app.UseAuthentication(); app.UseAuthorization(); -app.Run(); +// Map MCP endpoints with authorization +app.MapMcp(); + +// Configure the server URL +app.Urls.Add("http://localhost:7071"); + +Console.WriteLine("Starting MCP server with authorization at http://localhost:7071"); +Console.WriteLine("PRM Document URL: http://localhost:7071/.well-known/oauth-protected-resource"); + +Console.WriteLine(); +Console.WriteLine("To test the server:"); +Console.WriteLine("1. Use an MCP client that supports authorization"); +Console.WriteLine("2. When prompted for authorization, enter 'valid_token' to gain access"); +Console.WriteLine("3. Any other token value will be rejected with a 401 Unauthorized"); +Console.WriteLine(); +Console.WriteLine("Press Ctrl+C to stop the server"); + +await app.RunAsync(); diff --git a/samples/SecureWeatherServer/SecureWeatherServer.csproj b/samples/SecureWeatherServer/SecureWeatherServer.csproj index 4850a605..265cdacb 100644 --- a/samples/SecureWeatherServer/SecureWeatherServer.csproj +++ b/samples/SecureWeatherServer/SecureWeatherServer.csproj @@ -2,7 +2,7 @@ - net8.0 + net9.0 enable enable diff --git a/src/ModelContextProtocol/Auth/AuthorizationCodeOptions.cs b/src/ModelContextProtocol/Auth/AuthorizationCodeOptions.cs new file mode 100644 index 00000000..bc93621a --- /dev/null +++ b/src/ModelContextProtocol/Auth/AuthorizationCodeOptions.cs @@ -0,0 +1,47 @@ +namespace ModelContextProtocol.Auth; + +/// +/// Configuration options for the authorization code flow. +/// +public class AuthorizationCodeOptions +{ + /// + /// The client ID. + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// The client secret. + /// + public string? ClientSecret { get; set; } + + /// + /// The redirect URI. + /// + public Uri RedirectUri { get; set; } = null!; + + /// + /// The authorization endpoint. + /// + public Uri AuthorizationEndpoint { get; set; } = null!; + + /// + /// The token endpoint. + /// + public Uri TokenEndpoint { get; set; } = null!; + + /// + /// The scope to request. + /// + public string? Scope { get; set; } + + /// + /// PKCE values for the authorization flow. + /// + public PkceUtility.PkceValues PkceValues { get; set; } = null!; + + /// + /// A state value to protect against CSRF attacks. + /// + public string State { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/AuthorizationConfig.cs b/src/ModelContextProtocol/Auth/AuthorizationConfig.cs new file mode 100644 index 00000000..3f23573a --- /dev/null +++ b/src/ModelContextProtocol/Auth/AuthorizationConfig.cs @@ -0,0 +1,32 @@ +namespace ModelContextProtocol.Auth; + +/// +/// Configuration for OAuth authorization. +/// +public class AuthorizationConfig +{ + /// + /// The URI to redirect to after authentication. + /// + public Uri RedirectUri { get; set; } = null!; + + /// + /// The client ID to use for authentication, or null to register a new client. + /// + public string? ClientId { get; set; } + + /// + /// The client name to use for registration. + /// + public string? ClientName { get; set; } + + /// + /// The requested scopes. + /// + public IEnumerable? Scopes { get; set; } + + /// + /// The handler to invoke when authorization is required. + /// + public Func>? AuthorizationHandler { get; set; } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs b/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs index 242d47aa..9fe7535d 100644 --- a/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs +++ b/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs @@ -77,49 +77,3 @@ private static string GenerateCodeChallenge(string codeVerifier) return base64Url; } } - -/// -/// Configuration options for the authorization code flow. -/// -public class AuthorizationCodeOptions -{ - /// - /// The client ID. - /// - public string ClientId { get; set; } = string.Empty; - - /// - /// The client secret. - /// - public string? ClientSecret { get; set; } - - /// - /// The redirect URI. - /// - public Uri RedirectUri { get; set; } = null!; - - /// - /// The authorization endpoint. - /// - public Uri AuthorizationEndpoint { get; set; } = null!; - - /// - /// The token endpoint. - /// - public Uri TokenEndpoint { get; set; } = null!; - - /// - /// The scope to request. - /// - public string? Scope { get; set; } - - /// - /// PKCE values for the authorization flow. - /// - public PkceUtility.PkceValues PkceValues { get; set; } = null!; - - /// - /// A state value to protect against CSRF attacks. - /// - public string State { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/McpClientExtensions.cs b/src/ModelContextProtocol/Auth/McpClientExtensions.cs index 95d1bc5f..5b3fcefa 100644 --- a/src/ModelContextProtocol/Auth/McpClientExtensions.cs +++ b/src/ModelContextProtocol/Auth/McpClientExtensions.cs @@ -73,7 +73,7 @@ public static void ConfigureAuthorizationHandler( /// The HTTP client. /// The HTTP response with the 401 status code. /// The OAuth token response if authentication was successful. - public static async Task HandleUnauthorizedResponseAsync( + public static async Task HandleUnauthorizedResponseAsync( this HttpClient httpClient, HttpResponseMessage response) { @@ -145,34 +145,3 @@ public static async Task HandleUnauthorizedResponseAsync( } } } - -/// -/// Configuration for OAuth authorization. -/// -public class AuthorizationConfig -{ - /// - /// The URI to redirect to after authentication. - /// - public Uri RedirectUri { get; set; } = null!; - - /// - /// The client ID to use for authentication, or null to register a new client. - /// - public string? ClientId { get; set; } - - /// - /// The client name to use for registration. - /// - public string? ClientName { get; set; } - - /// - /// The requested scopes. - /// - public IEnumerable? Scopes { get; set; } - - /// - /// The handler to invoke when authorization is required. - /// - public Func>? AuthorizationHandler { get; set; } -} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs index 41dd2dd1..b7356053 100644 --- a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs +++ b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs @@ -24,7 +24,7 @@ public class OAuthAuthenticationService /// The client name to use for registration. /// The requested scopes. /// The OAuth token response. - public async Task HandleAuthenticationAsync( + public async Task HandleAuthenticationAsync( Uri resourceUri, string wwwAuthenticateHeader, Uri redirectUri, @@ -214,7 +214,7 @@ private async Task RegisterClientAsync(Uri registrat return registrationResponse; } - private async Task PerformAuthorizationCodeFlowAsync( + private async Task PerformAuthorizationCodeFlowAsync( AuthorizationServerMetadata authServerMetadata, string clientId, string? clientSecret, @@ -325,7 +325,7 @@ private string GenerateRandomString(int length) } // This method would be used in a real implementation after receiving the authorization code - private async Task ExchangeAuthorizationCodeForTokenAsync( + private async Task ExchangeAuthorizationCodeForTokenAsync( Uri tokenEndpoint, string clientId, string? clientSecret, @@ -361,7 +361,7 @@ private async Task ExchangeAuthorizationCodeForTokenAsync( response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); - var tokenResponse = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + var tokenResponse = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); if (tokenResponse == null) { throw new InvalidOperationException("Failed to parse token response."); diff --git a/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs b/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs index 75d2e291..96bfa728 100644 --- a/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs +++ b/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs @@ -13,7 +13,7 @@ public class OAuthDelegatingHandler : DelegatingHandler private readonly string? _clientName; private readonly IEnumerable? _scopes; private readonly Func>? _authorizationHandler; - private OAuthTokenResponse? _tokenResponse; + private OAuthToken? _tokenResponse; /// /// Initializes a new instance of the class. @@ -66,7 +66,7 @@ public OAuthDelegatingHandler( /// Manually set an OAuth token to use for subsequent requests. /// /// The OAuth token response. - public void SetToken(OAuthTokenResponse tokenResponse) + public void SetToken(OAuthToken tokenResponse) { _tokenResponse = tokenResponse; } diff --git a/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs b/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs index 426b737b..e96c447d 100644 --- a/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs +++ b/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs @@ -5,7 +5,7 @@ namespace ModelContextProtocol.Auth; /// /// Represents an OAuth token response. /// -public class OAuthTokenResponse +public class OAuthToken { /// /// The access token issued by the authorization server. diff --git a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs index 66b42b95..34a8beda 100644 --- a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs +++ b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs @@ -127,7 +127,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(AuthorizationServerMetadata))] [JsonSerializable(typeof(ClientRegistrationRequest))] [JsonSerializable(typeof(ClientRegistrationResponse))] - [JsonSerializable(typeof(OAuthTokenResponse))] + [JsonSerializable(typeof(OAuthToken))] [JsonSerializable(typeof(ProtectedResourceMetadata))] [ExcludeFromCodeCoverage] From b9ba2b922607803180bfa72653b57d36435d0d6a Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 1 May 2025 22:59:47 -0700 Subject: [PATCH 006/128] Update Program.cs --- samples/SecureWeatherServer/Program.cs | 80 +++++++++++++++++++------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/samples/SecureWeatherServer/Program.cs b/samples/SecureWeatherServer/Program.cs index efd2d743..13373716 100644 --- a/samples/SecureWeatherServer/Program.cs +++ b/samples/SecureWeatherServer/Program.cs @@ -1,6 +1,9 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Protocol.Types; -using Microsoft.AspNetCore.Authentication.JwtBearer; +using System.Security.Claims; +using System.Text.Encodings.Web; var builder = WebApplication.CreateBuilder(args); @@ -68,6 +71,7 @@ // Configure the OAuth metadata for this server metadata.AuthorizationServers.Add(new Uri("https://auth.example.com")); + // Define the scopes this server supports metadata.ScopesSupported.AddRange(["weather.read", "weather.write"]); @@ -75,26 +79,9 @@ metadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); }); -// Configure authentication -builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - // In a real app, you would configure proper JWT validation - options.Events = new JwtBearerEvents - { - OnMessageReceived = context => - { - // Simple demo authentication - in a real app, use proper JWT validation - var token = context.Request.Headers.Authorization.ToString().Replace("Bearer ", ""); - if (token == "valid_token") - { - // For demo purposes, simulate successful auth with a valid token - context.Success(); - } - return Task.CompletedTask; - } - }; - }); +// Configure authentication using the built-in authentication system +builder.Services.AddAuthentication("Bearer") + .AddScheme("Bearer", options => { }); // Add authorization policy for MCP builder.Services.AddAuthorization(options => @@ -130,3 +117,54 @@ Console.WriteLine("Press Ctrl+C to stop the server"); await app.RunAsync(); + +// Simple auth handler that validates a test token +// In a real app, you'd use a JWT handler or other proper authentication +class SimpleAuthHandler : AuthenticationHandler +{ + public SimpleAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + // Get the Authorization header + if (!Request.Headers.TryGetValue("Authorization", out var authHeader)) + { + return Task.FromResult(AuthenticateResult.Fail("Authorization header missing")); + } + + // Parse the token + var headerValue = authHeader.ToString(); + if (!headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(AuthenticateResult.Fail("Bearer token missing")); + } + + var token = headerValue["Bearer ".Length..].Trim(); + + // Validate the token - in a real app, this would validate a JWT + if (token != "valid_token") + { + return Task.FromResult(AuthenticateResult.Fail("Invalid token")); + } + + // Create a claims identity with required claims + var claims = new[] + { + new Claim(ClaimTypes.Name, "demo_user"), + new Claim(ClaimTypes.NameIdentifier, "user123"), + new Claim("scope", "weather.read") + }; + + var identity = new ClaimsIdentity(claims, "Bearer"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "Bearer"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} From 47f1937e6f83261b9eb5fdb412fcc39fe932072e Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 1 May 2025 23:00:39 -0700 Subject: [PATCH 007/128] Cleanup --- samples/SecureWeatherClient/Program.cs | 1 - samples/SecureWeatherClient/SecureWeatherClient.csproj | 1 - samples/SecureWeatherServer/SecureWeatherServer.csproj | 1 - 3 files changed, 3 deletions(-) diff --git a/samples/SecureWeatherClient/Program.cs b/samples/SecureWeatherClient/Program.cs index 3f761588..f6688b6b 100644 --- a/samples/SecureWeatherClient/Program.cs +++ b/samples/SecureWeatherClient/Program.cs @@ -1,4 +1,3 @@ -// filepath: c:\Users\ddelimarsky\source\csharp-sdk-anm\samples\SecureWeatherClient\Program.cs using System.Net.Http; using System.Threading.Tasks; using ModelContextProtocol.Auth; diff --git a/samples/SecureWeatherClient/SecureWeatherClient.csproj b/samples/SecureWeatherClient/SecureWeatherClient.csproj index 04387e91..f090cca5 100644 --- a/samples/SecureWeatherClient/SecureWeatherClient.csproj +++ b/samples/SecureWeatherClient/SecureWeatherClient.csproj @@ -1,4 +1,3 @@ - diff --git a/samples/SecureWeatherServer/SecureWeatherServer.csproj b/samples/SecureWeatherServer/SecureWeatherServer.csproj index 265cdacb..76cb3a24 100644 --- a/samples/SecureWeatherServer/SecureWeatherServer.csproj +++ b/samples/SecureWeatherServer/SecureWeatherServer.csproj @@ -1,4 +1,3 @@ - From 638bd35a2a054221a19ccfddf4fa9fe452d97ff9 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 1 May 2025 23:07:19 -0700 Subject: [PATCH 008/128] Update HttpMcpServerBuilderExtensions.cs --- .../HttpMcpServerBuilderExtensions.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index ca128cfe..bd2b0ca0 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -52,18 +52,18 @@ public static IMcpServerBuilder WithAuthorization( { ArgumentNullException.ThrowIfNull(builder); - // Register the resource metadata service - builder.Services.TryAddSingleton(); + // Create and register the resource metadata service + var resourceMetadataService = new ResourceMetadataService(); - // Configure the resource metadata if provided + // Apply configuration directly to the instance if (configureMetadata != null) { - builder.Services.Configure(service => - { - service.ConfigureMetadata(configureMetadata); - }); + resourceMetadataService.ConfigureMetadata(configureMetadata); } + // Register the configured instance as a singleton + builder.Services.AddSingleton(resourceMetadataService); + // Mark the service as having authorization enabled builder.Services.AddSingleton(); From d1f30f8fb0959f013c70c96c29ebbc632342a2e9 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 1 May 2025 23:11:45 -0700 Subject: [PATCH 009/128] Tweaks to config --- samples/SecureWeatherClient/Program.cs | 2 +- samples/SecureWeatherServer/Program.cs | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/samples/SecureWeatherClient/Program.cs b/samples/SecureWeatherClient/Program.cs index f6688b6b..8ba8b3e5 100644 --- a/samples/SecureWeatherClient/Program.cs +++ b/samples/SecureWeatherClient/Program.cs @@ -30,7 +30,7 @@ static async Task Main(string[] args) }; var httpClient = new HttpClient(oauthHandler); - var serverUrl = "http://localhost:55598/sse"; // Default server URL + var serverUrl = "http://localhost:7071/sse"; // Default server URL // Allow the user to specify a different server URL Console.WriteLine($"Server URL (press Enter for default: {serverUrl}):"); diff --git a/samples/SecureWeatherServer/Program.cs b/samples/SecureWeatherServer/Program.cs index 13373716..bcb51ce3 100644 --- a/samples/SecureWeatherServer/Program.cs +++ b/samples/SecureWeatherServer/Program.cs @@ -68,14 +68,9 @@ .WithHttpTransport() .WithAuthorization(metadata => { - // Configure the OAuth metadata for this server - metadata.AuthorizationServers.Add(new Uri("https://auth.example.com")); - - - // Define the scopes this server supports + metadata.AuthorizationServers.Add(new Uri("https://login.microsoftonline.com/a2213e1c-e51e-4304-9a0d-effe57f31655/v2.0")); metadata.ScopesSupported.AddRange(["weather.read", "weather.write"]); - - // Add optional documentation + metadata.BearerMethodsSupported.Add("header"); metadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); }); From 3713da6096d48fb8c9a9f6b9b165c4b147fa9f07 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 1 May 2025 23:18:29 -0700 Subject: [PATCH 010/128] Server configuration --- samples/SecureWeatherServer/Program.cs | 32 +++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/samples/SecureWeatherServer/Program.cs b/samples/SecureWeatherServer/Program.cs index bcb51ce3..4a1b6b19 100644 --- a/samples/SecureWeatherServer/Program.cs +++ b/samples/SecureWeatherServer/Program.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Options; using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Protocol.Types; +using ModelContextProtocol.AspNetCore.Auth; using System.Security.Claims; using System.Text.Encodings.Web; @@ -75,8 +76,13 @@ }); // Configure authentication using the built-in authentication system -builder.Services.AddAuthentication("Bearer") - .AddScheme("Bearer", options => { }); +// Register "Bearer" scheme with our SimpleAuthHandler and set it as the default scheme +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = "Bearer"; + options.DefaultChallengeScheme = "Bearer"; // Ensure challenges use Bearer scheme +}) +.AddScheme("Bearer", options => { }); // Add authorization policy for MCP builder.Services.AddAuthorization(options => @@ -117,12 +123,17 @@ // In a real app, you'd use a JWT handler or other proper authentication class SimpleAuthHandler : AuthenticationHandler { + // Directly inject the ResourceMetadataService instead of the options + private readonly ResourceMetadataService _resourceMetadataService; + public SimpleAuthHandler( IOptionsMonitor options, ILoggerFactory logger, - UrlEncoder encoder) + UrlEncoder encoder, + ResourceMetadataService resourceMetadataService) : base(options, logger, encoder) { + _resourceMetadataService = resourceMetadataService; } protected override Task HandleAuthenticateAsync() @@ -162,4 +173,19 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(AuthenticateResult.Success(ticket)); } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + // Always include the resource_metadata in the WWW-Authenticate header + var baseUrl = $"{Request.Scheme}://{Request.Host}"; + var metadataUrl = $"{baseUrl}/.well-known/oauth-protected-resource"; + + // Add WWW-Authenticate header with resource_metadata + Response.Headers.WWWAuthenticate = $"Bearer resource_metadata=\"{metadataUrl}\""; + + // Set 401 status code + Response.StatusCode = 401; + + return Task.CompletedTask; + } } From 9582111e36e747e2bb0ed87d097a341a9a5c6b84 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 1 May 2025 23:36:35 -0700 Subject: [PATCH 011/128] Update based on feedback --- samples/SecureWeatherServer/Program.cs | 24 ++------ .../McpAuthenticationResponseMiddleware.cs | 57 +++++++++++++++++++ ...henticationResponseMiddlewareExtensions.cs | 19 +++++++ .../Auth/McpWebApplicationExtensions.cs | 31 ++++++++++ .../HttpMcpServerBuilderExtensions.cs | 9 +++ .../McpEndpointRouteBuilderExtensions.cs | 12 ++++ 6 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddleware.cs create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddlewareExtensions.cs create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpWebApplicationExtensions.cs diff --git a/samples/SecureWeatherServer/Program.cs b/samples/SecureWeatherServer/Program.cs index 4a1b6b19..e486566e 100644 --- a/samples/SecureWeatherServer/Program.cs +++ b/samples/SecureWeatherServer/Program.cs @@ -70,13 +70,14 @@ .WithAuthorization(metadata => { metadata.AuthorizationServers.Add(new Uri("https://login.microsoftonline.com/a2213e1c-e51e-4304-9a0d-effe57f31655/v2.0")); - metadata.ScopesSupported.AddRange(["weather.read", "weather.write"]); metadata.BearerMethodsSupported.Add("header"); + metadata.ScopesSupported.AddRange(["weather.read", "weather.write"]); + + // Add optional documentation metadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); }); // Configure authentication using the built-in authentication system -// Register "Bearer" scheme with our SimpleAuthHandler and set it as the default scheme builder.Services.AddAuthentication(options => { options.DefaultScheme = "Bearer"; @@ -96,8 +97,8 @@ var app = builder.Build(); -// Set up the middleware pipeline app.UseAuthentication(); +app.UseMcpAuthenticationResponse(); app.UseAuthorization(); // Map MCP endpoints with authorization @@ -123,17 +124,12 @@ // In a real app, you'd use a JWT handler or other proper authentication class SimpleAuthHandler : AuthenticationHandler { - // Directly inject the ResourceMetadataService instead of the options - private readonly ResourceMetadataService _resourceMetadataService; - public SimpleAuthHandler( IOptionsMonitor options, ILoggerFactory logger, - UrlEncoder encoder, - ResourceMetadataService resourceMetadataService) + UrlEncoder encoder) : base(options, logger, encoder) { - _resourceMetadataService = resourceMetadataService; } protected override Task HandleAuthenticateAsync() @@ -176,16 +172,8 @@ protected override Task HandleAuthenticateAsync() protected override Task HandleChallengeAsync(AuthenticationProperties properties) { - // Always include the resource_metadata in the WWW-Authenticate header - var baseUrl = $"{Request.Scheme}://{Request.Host}"; - var metadataUrl = $"{baseUrl}/.well-known/oauth-protected-resource"; - - // Add WWW-Authenticate header with resource_metadata - Response.Headers.WWWAuthenticate = $"Bearer resource_metadata=\"{metadataUrl}\""; - - // Set 401 status code + // No need to manually set WWW-Authenticate header anymore - handled by middleware Response.StatusCode = 401; - return Task.CompletedTask; } } diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddleware.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddleware.cs new file mode 100644 index 00000000..ff30e4bc --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddleware.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.Auth; + +namespace ModelContextProtocol.AspNetCore.Auth; + +/// +/// Middleware that attaches WWW-Authenticate headers with resource metadata to 401 responses. +/// +public class McpAuthenticationResponseMiddleware +{ + private readonly RequestDelegate _next; + private readonly ResourceMetadataService _resourceMetadataService; + + /// + /// Initializes a new instance of the class. + /// + /// The next request delegate in the pipeline. + /// The resource metadata service. + public McpAuthenticationResponseMiddleware(RequestDelegate next, ResourceMetadataService resourceMetadataService) + { + _next = next; + _resourceMetadataService = resourceMetadataService; + } + + /// + /// Processes the request and adds WWW-Authenticate headers to 401 responses. + /// + /// The HTTP context. + /// A task representing the asynchronous operation. + public async Task InvokeAsync(HttpContext context) + { + // Add a callback to the OnStarting event which fires before the response headers are sent + context.Response.OnStarting(() => + { + // Check if the response is a 401 Unauthorized + if (context.Response.StatusCode == StatusCodes.Status401Unauthorized) + { + // Get the base URL of the request + var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}"; + var metadataPath = "/.well-known/oauth-protected-resource"; + var metadataUrl = $"{baseUrl}{metadataPath}"; + + // Add or update the WWW-Authenticate header + if (!context.Response.Headers.ContainsKey("WWW-Authenticate") || + !context.Response.Headers["WWW-Authenticate"].ToString().Contains("resource_metadata")) + { + context.Response.Headers.WWWAuthenticate = $"Bearer resource_metadata=\"{metadataUrl}\""; + } + } + return Task.CompletedTask; + }); + + // Call the next delegate/middleware in the pipeline + await _next(context); + } +} diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddlewareExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddlewareExtensions.cs new file mode 100644 index 00000000..8dc2bcd4 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddlewareExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; + +namespace ModelContextProtocol.AspNetCore.Auth; + +/// +/// Extension methods for the McpAuthenticationResponseMiddleware. +/// +public static class McpAuthenticationResponseMiddlewareExtensions +{ + /// + /// Adds the MCP authentication response middleware to the application pipeline. + /// + /// The application builder. + /// The application builder for chaining. + public static IApplicationBuilder UseWwwAuthenticateHeaderMiddleware(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpWebApplicationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpWebApplicationExtensions.cs new file mode 100644 index 00000000..5d8adcd0 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpWebApplicationExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace ModelContextProtocol.AspNetCore.Auth; + +/// +/// Extension methods for WebApplication to add MCP-specific middleware. +/// +public static class McpWebApplicationExtensions +{ + /// + /// Adds the MCP authentication response middleware to the application pipeline. + /// This middleware automatically adds the resource_metadata field to WWW-Authenticate headers in 401 responses. + /// + /// + /// This middleware should be registered AFTER UseAuthentication() but BEFORE UseAuthorization(). + /// + /// The web application. + /// The web application for chaining. + public static IApplicationBuilder UseMcpAuthenticationResponse(this IApplicationBuilder app) + { + if (app.ApplicationServices.GetService() == null) + { + throw new InvalidOperationException( + "McpAuthenticationResponseMarker service is not registered. " + + "Make sure you call AddMcpServer().WithAuthorization() first."); + } + + return app.UseMiddleware(); + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index bd2b0ca0..8e021899 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -87,6 +87,10 @@ public static IMcpServerBuilder WithAuthorization( }); }); + // Register the middleware for automatically adding WWW-Authenticate headers + // Store in DI that we need to use the middleware + builder.Services.AddSingleton(); + return builder; } } @@ -95,3 +99,8 @@ public static IMcpServerBuilder WithAuthorization( /// Marker class to indicate that MCP authorization has been configured. /// internal class McpAuthorizationMarker { } + +/// +/// Marker class to indicate that MCP authentication response middleware should be used. +/// +internal class McpAuthenticationResponseMarker { } diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index 6cd7186b..f2a41b39 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -56,6 +56,18 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo var authMarker = endpoints.ServiceProvider.GetService(); if (authMarker != null) { + // Check if the authentication response middleware is configured + var authResponseMarker = endpoints.ServiceProvider.GetService(); + if (authResponseMarker != null) + { + // Register the middleware to automatically add WWW-Authenticate headers to 401 responses + // We need to add this middleware to the parent app, not just the endpoint group + if (endpoints is IApplicationBuilder app) + { + app.UseWwwAuthenticateHeaderMiddleware(); + } + } + // Authorization is configured, so automatically map the OAuth protected resource endpoint var resourceMetadataService = endpoints.ServiceProvider.GetRequiredService(); From 8b89c2d3f18742165b2d2587e1605bec4f2c2c0a Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 1 May 2025 23:54:19 -0700 Subject: [PATCH 012/128] Pop the browser open - implement that in the SDK --- samples/SecureWeatherClient/Program.cs | 57 +++---- .../Auth/AuthorizationConfigExtensions.cs | 100 +++++++++++ .../Auth/OAuthAuthorizationHelpers.cs | 158 ++++++++++++++++++ 3 files changed, 282 insertions(+), 33 deletions(-) create mode 100644 src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs create mode 100644 src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs diff --git a/samples/SecureWeatherClient/Program.cs b/samples/SecureWeatherClient/Program.cs index 8ba8b3e5..e193be69 100644 --- a/samples/SecureWeatherClient/Program.cs +++ b/samples/SecureWeatherClient/Program.cs @@ -8,22 +8,26 @@ namespace SecureWeatherClient; class Program { - // The URI for our OAuth redirect - in a real app, this would be a registered URI or a local server - private static readonly Uri RedirectUri = new("http://localhost:1170/oauth-callback"); - static async Task Main(string[] args) { Console.WriteLine("MCP Secure Weather Client with OAuth Authentication"); Console.WriteLine("=================================================="); Console.WriteLine(); + // Create the authorization config with HTTP listener + var authConfig = new AuthorizationConfig + { + ClientId = "04f79824-ab56-4511-a7cb-d7deaea92dc0", + Scopes = ["User.Read"] + }.UseHttpListener(hostname: "localhost", listenPort: 1170); + // Create an HTTP client with OAuth handling var oauthHandler = new OAuthDelegatingHandler( - clientId: "04f79824-ab56-4511-a7cb-d7deaea92dc0", - redirectUri: RedirectUri, - clientName: "SecureWeatherClient", - scopes: ["weather.read"], - authorizationHandler: HandleAuthorizationRequestAsync) + redirectUri: authConfig.RedirectUri, + clientId: authConfig.ClientId, + clientName: authConfig.ClientName, + scopes: authConfig.Scopes, + authorizationHandler: authConfig.AuthorizationHandler) { // The OAuth handler needs an inner handler InnerHandler = new HttpClientHandler() @@ -42,6 +46,9 @@ static async Task Main(string[] args) Console.WriteLine(); Console.WriteLine($"Connecting to weather server at {serverUrl}..."); + Console.WriteLine("When prompted for authorization, a browser window will open automatically."); + Console.WriteLine("Complete the authentication in the browser, and this application will continue automatically."); + Console.WriteLine(); try { @@ -68,6 +75,15 @@ static async Task Main(string[] args) Console.WriteLine($"Found {tools.Count} tools on the server."); Console.WriteLine(); + + // Call the protected-data tool which requires authentication + if (tools.Any(t => t.Name == "protected-data")) + { + Console.WriteLine("Calling protected-data tool..."); + var result = await client.CallToolAsync("protected-data"); + Console.WriteLine("Result: " + result.Content[0].Text); + Console.WriteLine(); + } } catch (Exception ex) { @@ -81,29 +97,4 @@ static async Task Main(string[] args) Console.WriteLine("Press any key to exit..."); Console.ReadKey(); } - - /// - /// Handles the OAuth authorization request by showing the URL to the user and getting the authorization code. - /// In a real application, this would launch a browser and listen for the callback. - /// - private static Task HandleAuthorizationRequestAsync(Uri authorizationUri) - { - Console.WriteLine(); - Console.WriteLine("Authentication Required"); - Console.WriteLine("======================"); - Console.WriteLine(); - Console.WriteLine("Please open the following URL in your browser to authenticate:"); - Console.WriteLine(authorizationUri); - Console.WriteLine(); - Console.WriteLine("After authentication, you will be redirected to a page with a code."); - Console.WriteLine("Please enter the code parameter from the URL:"); - - var authorizationCode = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(authorizationCode)) - { - throw new InvalidOperationException("Authorization code is required."); - } - - return Task.FromResult(authorizationCode); - } } \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs b/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs new file mode 100644 index 00000000..8d3bff78 --- /dev/null +++ b/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs @@ -0,0 +1,100 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace ModelContextProtocol.Auth; + +/// +/// Extension methods for . +/// +public static class AuthorizationConfigExtensions +{ + /// + /// Configures the authorization config to use an HTTP listener for the OAuth authorization code flow. + /// + /// The authorization configuration to modify. + /// Optional function to open a browser. If not provided, a default implementation will be used. + /// The hostname to listen on. Defaults to "localhost". + /// The port to listen on. Defaults to 8888. + /// The redirect path for the HTTP listener. Defaults to "/callback". + /// The modified authorization configuration for chaining. + /// + /// + /// This method configures the authorization configuration to use an HTTP listener for the OAuth + /// authorization code flow. When authorization is required, the listener will automatically: + /// + /// + /// Start an HTTP listener on the specified hostname and port + /// Open the user's browser to the authorization URL + /// Wait for the authorization code to be received via the redirect URI + /// Return the authorization code to the SDK to complete the flow + /// + /// + /// This provides a seamless authorization experience without requiring manual user intervention + /// to copy/paste authorization codes. + /// + /// + public static AuthorizationConfig UseHttpListener( + this AuthorizationConfig config, + Func? openBrowser = null, + string hostname = "localhost", + int listenPort = 8888, + string redirectPath = "/callback") + { + // Set the redirect URI + config.RedirectUri = new Uri($"http://{hostname}:{listenPort}{redirectPath}"); + + // Use default browser-opening implementation if none provided + openBrowser ??= DefaultOpenBrowser; + + // Configure the handler + config.AuthorizationHandler = OAuthAuthorizationHelpers.CreateHttpListenerCallback( + openBrowser, + hostname, + listenPort, + redirectPath); + + return config; + } + + /// + /// Default implementation to open a URL in the default browser. + /// + private static Task DefaultOpenBrowser(string url) + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // On Windows, use the built-in Process.Start for URLs + Process.Start(new ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // On Linux, use xdg-open + Process.Start("xdg-open", url); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // On macOS, use open + Process.Start("open", url); + } + else + { + // Fallback for other platforms + throw new NotSupportedException("Automatic browser opening is not supported on this platform."); + } + + return Task.CompletedTask; + } + catch (Exception ex) + { + return Task.FromException(new InvalidOperationException($"Failed to open browser: {ex.Message}", ex)); + } + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs b/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs new file mode 100644 index 00000000..6509e23f --- /dev/null +++ b/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs @@ -0,0 +1,158 @@ +using System; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace ModelContextProtocol.Auth; + +/// +/// Provides helper methods for handling OAuth authorization. +/// +public static class OAuthAuthorizationHelpers +{ + /// + /// Creates an HTTP listener callback for handling OAuth 2.0 authorization code flow. + /// + /// A function that opens a browser with the given URL. + /// The hostname to listen on. Defaults to "localhost". + /// The port to listen on. Defaults to 8888. + /// The redirect path for the HTTP listener. Defaults to "/callback". + /// + /// A function that takes an authorization URI and returns a task that resolves to the authorization code. + /// + public static Func> CreateHttpListenerCallback( + Func openBrowser, + string hostname = "localhost", + int listenPort = 8888, + string redirectPath = "/callback") + { + return async (Uri authorizationUri) => + { + string redirectUri = $"http://{hostname}:{listenPort}{redirectPath}"; + + // Add the redirect_uri parameter to the authorization URI if it's not already present + string authUrl = authorizationUri.ToString(); + if (!authUrl.Contains("redirect_uri=")) + { + var separator = authUrl.Contains("?") ? "&" : "?"; + authUrl = $"{authUrl}{separator}redirect_uri={WebUtility.UrlEncode(redirectUri)}"; + } + + var authCodeTcs = new TaskCompletionSource(); + + // Ensure the path has a trailing slash for the HttpListener prefix + string listenerPrefix = $"http://{hostname}:{listenPort}{redirectPath}"; + if (!listenerPrefix.EndsWith("/")) + { + listenerPrefix += "/"; + } + + using var listener = new HttpListener(); + listener.Prefixes.Add(listenerPrefix); + + // Start the listener BEFORE opening the browser + try + { + listener.Start(); + } + catch (HttpListenerException ex) + { + throw new InvalidOperationException($"Failed to start HTTP listener on {listenerPrefix}: {ex.Message}"); + } + + // Create a cancellation token source with a timeout + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + + _ = Task.Run(async () => + { + try + { + // GetContextAsync doesn't accept a cancellation token, so we need to handle cancellation manually + var contextTask = listener.GetContextAsync(); + var completedTask = await Task.WhenAny(contextTask, Task.Delay(Timeout.Infinite, cts.Token)); + + if (completedTask == contextTask) + { + var context = await contextTask; + var request = context.Request; + var response = context.Response; + + string? code = request.QueryString["code"]; + string? error = request.QueryString["error"]; + string html; + string? resultCode = null; + + if (!string.IsNullOrEmpty(error)) + { + html = $"

Authorization Failed

Error: {WebUtility.HtmlEncode(error)}

"; + } + else if (string.IsNullOrEmpty(code)) + { + html = "

Authorization Failed

No authorization code received.

"; + } + else + { + html = "

Authorization Successful

You may now close this window.

"; + resultCode = code; + } + + try + { + // Send response to browser + byte[] buffer = Encoding.UTF8.GetBytes(html); + response.ContentType = "text/html"; + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer, 0, buffer.Length); + + // IMPORTANT: Explicitly close the response to ensure it's fully sent + response.Close(); + + // Now that we've finished processing the browser response, + // we can safely signal completion or failure with the auth code + if (resultCode != null) + { + authCodeTcs.TrySetResult(resultCode); + } + else if (!string.IsNullOrEmpty(error)) + { + authCodeTcs.TrySetException(new InvalidOperationException($"Authorization failed: {error}")); + } + else + { + authCodeTcs.TrySetException(new InvalidOperationException("No authorization code received")); + } + } + catch (Exception ex) + { + authCodeTcs.TrySetException(new InvalidOperationException($"Error processing browser response: {ex.Message}")); + } + } + } + catch (Exception ex) + { + authCodeTcs.TrySetException(ex); + } + }); + + // Now open the browser AFTER the listener is started + await openBrowser(authUrl); + + try + { + // Use a timeout to avoid hanging indefinitely + string authCode = await authCodeTcs.Task.WaitAsync(cts.Token); + return authCode; + } + catch (OperationCanceledException) + { + throw new InvalidOperationException("Authorization timed out after 5 minutes."); + } + finally + { + // Ensure the listener is stopped when we're done + listener.Stop(); + } + }; + } +} \ No newline at end of file From c67ef6c3db3548885990c0aa4be9237b171f4146 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 00:09:20 -0700 Subject: [PATCH 013/128] Tweaks to handles --- samples/SecureWeatherClient/Program.cs | 81 ++++++++++--- .../Auth/McpClientExtensions.cs | 58 +++------- .../Auth/OAuthAuthenticationService.cs | 68 ++++++++--- .../Auth/OAuthAuthorizationHelpers.cs | 109 ++++++++++++++++++ 4 files changed, 239 insertions(+), 77 deletions(-) diff --git a/samples/SecureWeatherClient/Program.cs b/samples/SecureWeatherClient/Program.cs index e193be69..033aa060 100644 --- a/samples/SecureWeatherClient/Program.cs +++ b/samples/SecureWeatherClient/Program.cs @@ -14,24 +14,49 @@ static async Task Main(string[] args) Console.WriteLine("=================================================="); Console.WriteLine(); - // Create the authorization config with HTTP listener - var authConfig = new AuthorizationConfig - { - ClientId = "04f79824-ab56-4511-a7cb-d7deaea92dc0", - Scopes = ["User.Read"] - }.UseHttpListener(hostname: "localhost", listenPort: 1170); + Console.WriteLine("Select authentication mode:"); + Console.WriteLine("1. Normal OAuth flow with browser"); + Console.WriteLine("2. Mock authentication (accepts any token for testing)"); + Console.Write("Enter your choice (1-2): "); + var choice = Console.ReadLine()?.Trim(); // Create an HTTP client with OAuth handling - var oauthHandler = new OAuthDelegatingHandler( - redirectUri: authConfig.RedirectUri, - clientId: authConfig.ClientId, - clientName: authConfig.ClientName, - scopes: authConfig.Scopes, - authorizationHandler: authConfig.AuthorizationHandler) + DelegatingHandler oauthHandler; + + if (choice == "2") + { + Console.WriteLine("\nUsing mock authentication for testing (no browser will open).\n"); + + // Create a mock OAuth handler that always returns a token + oauthHandler = new MockOAuthHandler() + { + InnerHandler = new HttpClientHandler() + }; + } + else { - // The OAuth handler needs an inner handler - InnerHandler = new HttpClientHandler() - }; + Console.WriteLine("\nUsing standard OAuth flow with browser authentication.\n"); + + // Create the authorization config with HTTP listener + var authConfig = new AuthorizationConfig + { + ClientId = "04f79824-ab56-4511-a7cb-d7deaea92dc0", + ClientName = "SecureWeatherClient", + Scopes = ["weather.read"] + }.UseHttpListener(hostname: "localhost", listenPort: 1170); + + // Create an HTTP client with OAuth handling + oauthHandler = new OAuthDelegatingHandler( + redirectUri: authConfig.RedirectUri, + clientId: authConfig.ClientId, + clientName: authConfig.ClientName, + scopes: authConfig.Scopes, + authorizationHandler: authConfig.AuthorizationHandler) + { + // The OAuth handler needs an inner handler + InnerHandler = new HttpClientHandler() + }; + } var httpClient = new HttpClient(oauthHandler); var serverUrl = "http://localhost:7071/sse"; // Default server URL @@ -46,8 +71,13 @@ static async Task Main(string[] args) Console.WriteLine(); Console.WriteLine($"Connecting to weather server at {serverUrl}..."); - Console.WriteLine("When prompted for authorization, a browser window will open automatically."); - Console.WriteLine("Complete the authentication in the browser, and this application will continue automatically."); + + if (choice != "2") + { + Console.WriteLine("When prompted for authorization, a browser window will open automatically."); + Console.WriteLine("Complete the authentication in the browser, and this application will continue automatically."); + } + Console.WriteLine(); try @@ -97,4 +127,21 @@ static async Task Main(string[] args) Console.WriteLine("Press any key to exit..."); Console.ReadKey(); } +} + +/// +/// A mock OAuth handler that always returns a predefined token without going through the OAuth flow. +/// This is useful for testing without requiring a real OAuth server. +/// +public class MockOAuthHandler : DelegatingHandler +{ + private readonly string _mockToken = "mock_test_token_" + Guid.NewGuid().ToString("N"); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Always attach the mock token to outgoing requests + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _mockToken); + + return base.SendAsync(request, cancellationToken); + } } \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/McpClientExtensions.cs b/src/ModelContextProtocol/Auth/McpClientExtensions.cs index 5b3fcefa..b484c5c6 100644 --- a/src/ModelContextProtocol/Auth/McpClientExtensions.cs +++ b/src/ModelContextProtocol/Auth/McpClientExtensions.cs @@ -96,52 +96,26 @@ public static async Task HandleUnauthorizedResponseAsync( throw new InvalidOperationException("The HTTP client has not been configured for authorization handling. Call ConfigureAuthorizationHandler() first."); } - // Create OAuthAuthenticationService - var authService = new OAuthAuthenticationService(); + // Create OAuthAuthenticationService - use appropriate constructor based on whether we have a handler + OAuthAuthenticationService authService = config.AuthorizationHandler != null + ? new OAuthAuthenticationService(config.AuthorizationHandler) + : new OAuthAuthenticationService(); // Get resource URI var resourceUri = response.RequestMessage?.RequestUri ?? throw new InvalidOperationException("Request URI is not available."); // Start the authentication flow - try - { - var tokenResponse = await authService.HandleAuthenticationAsync( - resourceUri, - wwwAuthenticateHeader, - config.RedirectUri, - config.ClientId, - config.ClientName, - config.Scopes); - - // Attach the access token to future requests - httpClient.AttachToken(tokenResponse.AccessToken); - - return tokenResponse; - } - catch (NotImplementedException ex) when (ex.Message.Contains("Authorization requires user interaction")) - { - // Extract the authorization URL from the exception message - var authUrlStart = ex.Message.IndexOf("http"); - var authUrlEnd = ex.Message.IndexOf("\n", authUrlStart); - var authUrl = ex.Message.Substring(authUrlStart, authUrlEnd - authUrlStart); - - // Check if a handler is registered - if (config.AuthorizationHandler != null) - { - // Call the handler to get the authorization code - var authCode = await config.AuthorizationHandler(new Uri(authUrl)); - - // In a real implementation, we would use the authorization code to get a token - // For now, throw an exception with instructions - throw new NotImplementedException( - "Authorization code acquired, but token exchange is not implemented. " + - "In a real implementation, this would call ExchangeAuthorizationCodeForTokenAsync."); - } - else - { - // Re-throw the original exception - throw; - } - } + var tokenResponse = await authService.HandleAuthenticationAsync( + resourceUri, + wwwAuthenticateHeader, + config.RedirectUri, + config.ClientId, + config.ClientName, + config.Scopes); + + // Attach the access token to future requests + httpClient.AttachToken(tokenResponse.AccessToken); + + return tokenResponse; } } diff --git a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs index b7356053..1e419220 100644 --- a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs +++ b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs @@ -13,6 +13,23 @@ namespace ModelContextProtocol.Auth; public class OAuthAuthenticationService { private static readonly HttpClient _httpClient = new(); + private readonly Func>? _authorizationHandler; + + /// + /// Initializes a new instance of the class. + /// + public OAuthAuthenticationService() + { + } + + /// + /// Initializes a new instance of the class with an authorization handler. + /// + /// A handler to invoke when authorization is required. + public OAuthAuthenticationService(Func> authorizationHandler) + { + _authorizationHandler = authorizationHandler ?? throw new ArgumentNullException(nameof(authorizationHandler)); + } /// /// Handles the OAuth authentication flow when a 401 Unauthorized response is received. @@ -23,6 +40,7 @@ public class OAuthAuthenticationService /// The client ID to use for authentication, or null to register a new client. /// The client name to use for registration. /// The requested scopes. + /// A handler to invoke when authorization is required. If not provided, the handler from the constructor will be used. /// The OAuth token response. public async Task HandleAuthenticationAsync( Uri resourceUri, @@ -30,8 +48,12 @@ public async Task HandleAuthenticationAsync( Uri redirectUri, string? clientId = null, string? clientName = null, - IEnumerable? scopes = null) + IEnumerable? scopes = null, + Func>? authorizationHandler = null) { + // Use the provided authorization handler or fall back to the one from the constructor + var effectiveAuthHandler = authorizationHandler ?? _authorizationHandler; + // Extract resource metadata URL from WWW-Authenticate header var resourceMetadataUri = ExtractResourceMetadataUri(wwwAuthenticateHeader); if (resourceMetadataUri == null) @@ -88,7 +110,8 @@ public async Task HandleAuthenticationAsync( effectiveClientId, // This is now guaranteed to be non-null clientSecret, redirectUri, - scopes?.ToList() ?? resourceMetadata.ScopesSupported); + scopes?.ToList() ?? resourceMetadata.ScopesSupported, + effectiveAuthHandler); return tokenResponse; } @@ -219,7 +242,8 @@ private async Task PerformAuthorizationCodeFlowAsync( string clientId, string? clientSecret, Uri redirectUri, - IEnumerable scopes) + IEnumerable scopes, + Func>? authorizationHandler) { // Generate PKCE code verifier and challenge var codeVerifier = GenerateCodeVerifier(); @@ -233,26 +257,34 @@ private async Task PerformAuthorizationCodeFlowAsync( codeChallenge, scopes); - // At this point, in a real application, you would redirect the user to the authorizationUrl - // and then handle the callback to redirectUri with the authorization code. - // For this implementation, we'll assume the code is obtained externally and passed to us. + // Check if an authorization handler is available + if (authorizationHandler != null) + { + try + { + // Get the authorization code using the provided handler + string authorizationCode = await authorizationHandler(new Uri(authorizationUrl)); + + // Exchange the authorization code for a token + return await ExchangeAuthorizationCodeForTokenAsync( + authServerMetadata.TokenEndpoint, + clientId, + clientSecret, + redirectUri, + authorizationCode, + codeVerifier); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to complete OAuth authorization flow: {ex.Message}", ex); + } + } - // Since we can't actually perform the browser interaction in this service, - // we'll throw with instructions + // No authorization handler available, throw with instructions throw new NotImplementedException( $"Authorization requires user interaction. Please direct the user to: {authorizationUrl}\n" + $"After authorization, the user will be redirected to: {redirectUri}?code=[authorization_code]\n" + $"You need to handle this redirect and extract the authorization code to complete the flow."); - - // In a real implementation, after getting the authorization code: - // var authorizationCode = GetAuthorizationCodeFromRedirect(); - // return await ExchangeAuthorizationCodeForTokenAsync( - // authServerMetadata.TokenEndpoint, - // clientId, - // clientSecret, - // redirectUri, - // authorizationCode, - // codeVerifier); } private string GenerateCodeVerifier() diff --git a/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs b/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs index 6509e23f..7b344060 100644 --- a/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs @@ -1,8 +1,13 @@ using System; +using System.Collections.Generic; using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using ModelContextProtocol.Utils.Json; namespace ModelContextProtocol.Auth; @@ -11,6 +16,8 @@ namespace ModelContextProtocol.Auth; /// public static class OAuthAuthorizationHelpers { + private static readonly HttpClient _httpClient = new(); + /// /// Creates an HTTP listener callback for handling OAuth 2.0 authorization code flow. /// @@ -155,4 +162,106 @@ public static Func> CreateHttpListenerCallback( } }; } + + /// + /// Exchanges an authorization code for an OAuth token. + /// + /// The token endpoint URI. + /// The client ID. + /// The client secret, if any. + /// The redirect URI used in the authorization request. + /// The authorization code received from the authorization server. + /// The PKCE code verifier. + /// A cancellation token to cancel the operation. + /// The OAuth token response. + public static async Task ExchangeAuthorizationCodeForTokenAsync( + Uri tokenEndpoint, + string clientId, + string? clientSecret, + Uri redirectUri, + string authorizationCode, + string codeVerifier, + CancellationToken cancellationToken = default) + { + var tokenRequest = new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = authorizationCode, + ["redirect_uri"] = redirectUri.ToString(), + ["client_id"] = clientId, + ["code_verifier"] = codeVerifier + }; + + var requestContent = new FormUrlEncodedContent(tokenRequest); + + HttpResponseMessage response; + if (!string.IsNullOrEmpty(clientSecret)) + { + // Add client authentication if secret is available + var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); + using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) + { + Content = requestContent + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authValue); + response = await _httpClient.SendAsync(request, cancellationToken); + } + else + { + response = await _httpClient.PostAsync(tokenEndpoint, requestContent, cancellationToken); + } + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var tokenResponse = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + if (tokenResponse == null) + { + throw new InvalidOperationException("Failed to parse token response."); + } + + return tokenResponse; + } + + /// + /// Creates a complete OAuth authorization code flow handler that automatically exchanges the code for a token. + /// + /// The token endpoint URI. + /// The client ID. + /// The client secret, if any. + /// The redirect URI used in the authorization request. + /// The PKCE code verifier. + /// A function that opens a browser with the given URL. + /// The hostname to listen on. Defaults to "localhost". + /// The port to listen on. Defaults to 8888. + /// The redirect path for the HTTP listener. Defaults to "/callback". + /// A function that takes an authorization URI and returns a task that resolves to the OAuth token. + public static Func> CreateCompleteOAuthFlowHandler( + Uri tokenEndpoint, + string clientId, + string? clientSecret, + Uri redirectUri, + string codeVerifier, + Func openBrowser, + string hostname = "localhost", + int listenPort = 8888, + string redirectPath = "/callback") + { + var codeHandler = CreateHttpListenerCallback(openBrowser, hostname, listenPort, redirectPath); + + return async (authorizationUri) => + { + // First get the authorization code + string authorizationCode = await codeHandler(authorizationUri); + + // Then exchange it for a token + return await ExchangeAuthorizationCodeForTokenAsync( + tokenEndpoint, + clientId, + clientSecret, + redirectUri, + authorizationCode, + codeVerifier); + }; + } } \ No newline at end of file From a43984015f18d3a3e8ed1954b466112e275569fd Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 00:13:24 -0700 Subject: [PATCH 014/128] Mock handler --- samples/SecureWeatherClient/Program.cs | 81 ++++++-------------------- samples/SecureWeatherServer/Program.cs | 17 ++++-- 2 files changed, 28 insertions(+), 70 deletions(-) diff --git a/samples/SecureWeatherClient/Program.cs b/samples/SecureWeatherClient/Program.cs index 033aa060..e193be69 100644 --- a/samples/SecureWeatherClient/Program.cs +++ b/samples/SecureWeatherClient/Program.cs @@ -14,49 +14,24 @@ static async Task Main(string[] args) Console.WriteLine("=================================================="); Console.WriteLine(); - Console.WriteLine("Select authentication mode:"); - Console.WriteLine("1. Normal OAuth flow with browser"); - Console.WriteLine("2. Mock authentication (accepts any token for testing)"); - Console.Write("Enter your choice (1-2): "); - var choice = Console.ReadLine()?.Trim(); + // Create the authorization config with HTTP listener + var authConfig = new AuthorizationConfig + { + ClientId = "04f79824-ab56-4511-a7cb-d7deaea92dc0", + Scopes = ["User.Read"] + }.UseHttpListener(hostname: "localhost", listenPort: 1170); // Create an HTTP client with OAuth handling - DelegatingHandler oauthHandler; - - if (choice == "2") - { - Console.WriteLine("\nUsing mock authentication for testing (no browser will open).\n"); - - // Create a mock OAuth handler that always returns a token - oauthHandler = new MockOAuthHandler() - { - InnerHandler = new HttpClientHandler() - }; - } - else + var oauthHandler = new OAuthDelegatingHandler( + redirectUri: authConfig.RedirectUri, + clientId: authConfig.ClientId, + clientName: authConfig.ClientName, + scopes: authConfig.Scopes, + authorizationHandler: authConfig.AuthorizationHandler) { - Console.WriteLine("\nUsing standard OAuth flow with browser authentication.\n"); - - // Create the authorization config with HTTP listener - var authConfig = new AuthorizationConfig - { - ClientId = "04f79824-ab56-4511-a7cb-d7deaea92dc0", - ClientName = "SecureWeatherClient", - Scopes = ["weather.read"] - }.UseHttpListener(hostname: "localhost", listenPort: 1170); - - // Create an HTTP client with OAuth handling - oauthHandler = new OAuthDelegatingHandler( - redirectUri: authConfig.RedirectUri, - clientId: authConfig.ClientId, - clientName: authConfig.ClientName, - scopes: authConfig.Scopes, - authorizationHandler: authConfig.AuthorizationHandler) - { - // The OAuth handler needs an inner handler - InnerHandler = new HttpClientHandler() - }; - } + // The OAuth handler needs an inner handler + InnerHandler = new HttpClientHandler() + }; var httpClient = new HttpClient(oauthHandler); var serverUrl = "http://localhost:7071/sse"; // Default server URL @@ -71,13 +46,8 @@ static async Task Main(string[] args) Console.WriteLine(); Console.WriteLine($"Connecting to weather server at {serverUrl}..."); - - if (choice != "2") - { - Console.WriteLine("When prompted for authorization, a browser window will open automatically."); - Console.WriteLine("Complete the authentication in the browser, and this application will continue automatically."); - } - + Console.WriteLine("When prompted for authorization, a browser window will open automatically."); + Console.WriteLine("Complete the authentication in the browser, and this application will continue automatically."); Console.WriteLine(); try @@ -127,21 +97,4 @@ static async Task Main(string[] args) Console.WriteLine("Press any key to exit..."); Console.ReadKey(); } -} - -/// -/// A mock OAuth handler that always returns a predefined token without going through the OAuth flow. -/// This is useful for testing without requiring a real OAuth server. -/// -public class MockOAuthHandler : DelegatingHandler -{ - private readonly string _mockToken = "mock_test_token_" + Guid.NewGuid().ToString("N"); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - // Always attach the mock token to outgoing requests - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _mockToken); - - return base.SendAsync(request, cancellationToken); - } } \ No newline at end of file diff --git a/samples/SecureWeatherServer/Program.cs b/samples/SecureWeatherServer/Program.cs index e486566e..56063a70 100644 --- a/samples/SecureWeatherServer/Program.cs +++ b/samples/SecureWeatherServer/Program.cs @@ -110,17 +110,19 @@ Console.WriteLine("Starting MCP server with authorization at http://localhost:7071"); Console.WriteLine("PRM Document URL: http://localhost:7071/.well-known/oauth-protected-resource"); +Console.WriteLine(); +Console.WriteLine("Testing mode: Server will accept ANY non-empty token for authentication"); Console.WriteLine(); Console.WriteLine("To test the server:"); Console.WriteLine("1. Use an MCP client that supports authorization"); -Console.WriteLine("2. When prompted for authorization, enter 'valid_token' to gain access"); -Console.WriteLine("3. Any other token value will be rejected with a 401 Unauthorized"); +Console.WriteLine("2. The server will accept any non-empty token sent by the client"); +Console.WriteLine("3. Tokens will be logged to the console for debugging"); Console.WriteLine(); Console.WriteLine("Press Ctrl+C to stop the server"); await app.RunAsync(); -// Simple auth handler that validates a test token +// Simple auth handler that accepts any non-empty token for testing // In a real app, you'd use a JWT handler or other proper authentication class SimpleAuthHandler : AuthenticationHandler { @@ -149,12 +151,15 @@ protected override Task HandleAuthenticateAsync() var token = headerValue["Bearer ".Length..].Trim(); - // Validate the token - in a real app, this would validate a JWT - if (token != "valid_token") + // Accept any non-empty token for testing purposes + if (string.IsNullOrEmpty(token)) { - return Task.FromResult(AuthenticateResult.Fail("Invalid token")); + return Task.FromResult(AuthenticateResult.Fail("Token cannot be empty")); } + // Log the received token for debugging + Console.WriteLine($"Received and accepted token: {token}"); + // Create a claims identity with required claims var claims = new[] { From 932e678956c346225a0e698eba1e12b56730f632 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 00:21:45 -0700 Subject: [PATCH 015/128] Make sure I am not introducing unexpected changes --- .../Auth/ResourceMetadataEndpointHandler.cs | 2 +- .../McpEndpointRouteBuilderExtensions.cs | 4 ++-- tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs | 3 ++- .../MapMcpStreamableHttpTests.cs | 3 ++- tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs | 3 ++- tests/ModelContextProtocol.TestSseServer/Program.cs | 1 - 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs index 961bf0e7..f97a0d26 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Http; using ModelContextProtocol.AspNetCore.Auth; -namespace ModelContextProtocol.AspNetCore; +namespace Microsoft.AspNetCore.Builder; public static partial class McpEndpointRouteBuilderExtensions { diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index f2a41b39..28e97445 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -3,12 +3,12 @@ using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.AspNetCore.Auth; -using ModelContextProtocol.Auth; using ModelContextProtocol.Protocol.Messages; using System.Diagnostics.CodeAnalysis; -namespace ModelContextProtocol.AspNetCore; +namespace Microsoft.AspNetCore.Builder; /// /// Provides extension methods for to add MCP endpoints. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs index fef2f3c4..d385623a 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; namespace ModelContextProtocol.AspNetCore.Tests; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index dfbf26ae..30632a8e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; namespace ModelContextProtocol.AspNetCore.Tests; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index 47e9344e..70b028e2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index 2e18d30b..72a271cf 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Connections; -using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Server; using ModelContextProtocol.Utils.Json; From 2151d00ee7a185cd573a309d50190eec6fb36199 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 00:23:15 -0700 Subject: [PATCH 016/128] Rename projects --- ...SecureWeatherClient.csproj => ProtectedMCPServerClient.csproj} | 0 .../{SecureWeatherServer.csproj => ProtectedMCPServer.csproj} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename samples/SecureWeatherClient/{SecureWeatherClient.csproj => ProtectedMCPServerClient.csproj} (100%) rename samples/SecureWeatherServer/{SecureWeatherServer.csproj => ProtectedMCPServer.csproj} (100%) diff --git a/samples/SecureWeatherClient/SecureWeatherClient.csproj b/samples/SecureWeatherClient/ProtectedMCPServerClient.csproj similarity index 100% rename from samples/SecureWeatherClient/SecureWeatherClient.csproj rename to samples/SecureWeatherClient/ProtectedMCPServerClient.csproj diff --git a/samples/SecureWeatherServer/SecureWeatherServer.csproj b/samples/SecureWeatherServer/ProtectedMCPServer.csproj similarity index 100% rename from samples/SecureWeatherServer/SecureWeatherServer.csproj rename to samples/SecureWeatherServer/ProtectedMCPServer.csproj From 3bfc258828b9a12d52eaf7fb750dca6155099230 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 00:25:01 -0700 Subject: [PATCH 017/128] Update sample project name and location --- ModelContextProtocol.sln | 4 ++-- .../{SecureWeatherClient => ProtectedMCPClient}/Program.cs | 2 +- .../ProtectedMCPServerClient.csproj | 0 .../{SecureWeatherServer => ProtectedMCPServer}/Program.cs | 0 .../Properties/launchSettings.json | 2 +- .../ProtectedMCPServer.csproj | 0 .../Tools/HttpClientExt.cs | 0 .../Tools/WeatherTools.cs | 2 +- 8 files changed, 5 insertions(+), 5 deletions(-) rename samples/{SecureWeatherClient => ProtectedMCPClient}/Program.cs (99%) rename samples/{SecureWeatherClient => ProtectedMCPClient}/ProtectedMCPServerClient.csproj (100%) rename samples/{SecureWeatherServer => ProtectedMCPServer}/Program.cs (100%) rename samples/{SecureWeatherServer => ProtectedMCPServer}/Properties/launchSettings.json (89%) rename samples/{SecureWeatherServer => ProtectedMCPServer}/ProtectedMCPServer.csproj (100%) rename samples/{SecureWeatherServer => ProtectedMCPServer}/Tools/HttpClientExt.cs (100%) rename samples/{SecureWeatherServer => ProtectedMCPServer}/Tools/WeatherTools.cs (98%) diff --git a/ModelContextProtocol.sln b/ModelContextProtocol.sln index 0a72e940..39fb0f94 100644 --- a/ModelContextProtocol.sln +++ b/ModelContextProtocol.sln @@ -56,9 +56,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.AspNet EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.AspNetCore.Tests", "tests\ModelContextProtocol.AspNetCore.Tests\ModelContextProtocol.AspNetCore.Tests.csproj", "{85557BA6-3D29-4C95-A646-2A972B1C2F25}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureWeatherClient", "samples\SecureWeatherClient\SecureWeatherClient.csproj", "{CF41BB82-4E3E-5E86-BCB6-0DF3A1B48CF2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedMCPClient", "samples\ProtectedMCPClient\ProtectedMCPClient.csproj", "{CF41BB82-4E3E-5E86-BCB6-0DF3A1B48CF2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureWeatherServer", "samples\SecureWeatherServer\SecureWeatherServer.csproj", "{80944644-54DC-2AFF-C60E-9885AD81E509}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedMCPServer", "samples\ProtectedMCPServer\ProtectedMCPServer.csproj", "{80944644-54DC-2AFF-C60E-9885AD81E509}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/samples/SecureWeatherClient/Program.cs b/samples/ProtectedMCPClient/Program.cs similarity index 99% rename from samples/SecureWeatherClient/Program.cs rename to samples/ProtectedMCPClient/Program.cs index e193be69..9746e631 100644 --- a/samples/SecureWeatherClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -4,7 +4,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol.Transport; -namespace SecureWeatherClient; +namespace ProtectedMCPClient; class Program { diff --git a/samples/SecureWeatherClient/ProtectedMCPServerClient.csproj b/samples/ProtectedMCPClient/ProtectedMCPServerClient.csproj similarity index 100% rename from samples/SecureWeatherClient/ProtectedMCPServerClient.csproj rename to samples/ProtectedMCPClient/ProtectedMCPServerClient.csproj diff --git a/samples/SecureWeatherServer/Program.cs b/samples/ProtectedMCPServer/Program.cs similarity index 100% rename from samples/SecureWeatherServer/Program.cs rename to samples/ProtectedMCPServer/Program.cs diff --git a/samples/SecureWeatherServer/Properties/launchSettings.json b/samples/ProtectedMCPServer/Properties/launchSettings.json similarity index 89% rename from samples/SecureWeatherServer/Properties/launchSettings.json rename to samples/ProtectedMCPServer/Properties/launchSettings.json index f190756c..03646532 100644 --- a/samples/SecureWeatherServer/Properties/launchSettings.json +++ b/samples/ProtectedMCPServer/Properties/launchSettings.json @@ -1,6 +1,6 @@ { "profiles": { - "SecureWeatherServer": { + "ProtectedMCPServer": { "commandName": "Project", "launchBrowser": true, "environmentVariables": { diff --git a/samples/SecureWeatherServer/ProtectedMCPServer.csproj b/samples/ProtectedMCPServer/ProtectedMCPServer.csproj similarity index 100% rename from samples/SecureWeatherServer/ProtectedMCPServer.csproj rename to samples/ProtectedMCPServer/ProtectedMCPServer.csproj diff --git a/samples/SecureWeatherServer/Tools/HttpClientExt.cs b/samples/ProtectedMCPServer/Tools/HttpClientExt.cs similarity index 100% rename from samples/SecureWeatherServer/Tools/HttpClientExt.cs rename to samples/ProtectedMCPServer/Tools/HttpClientExt.cs diff --git a/samples/SecureWeatherServer/Tools/WeatherTools.cs b/samples/ProtectedMCPServer/Tools/WeatherTools.cs similarity index 98% rename from samples/SecureWeatherServer/Tools/WeatherTools.cs rename to samples/ProtectedMCPServer/Tools/WeatherTools.cs index ad7a95b8..f8b2abc6 100644 --- a/samples/SecureWeatherServer/Tools/WeatherTools.cs +++ b/samples/ProtectedMCPServer/Tools/WeatherTools.cs @@ -4,7 +4,7 @@ using System.Globalization; using System.Text.Json; -namespace SecureWeatherServer.Tools; +namespace ProtectedMCPServer.Tools; [McpServerToolType] public sealed class WeatherTools From 70146ddab72ba5fd8f9b1e25743ca5f2dc493c98 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 00:29:32 -0700 Subject: [PATCH 018/128] Make sure definitions are not changed --- .../Auth/ResourceMetadataEndpointHandler.cs | 36 +++++++++---------- .../McpEndpointRouteBuilderExtensions.cs | 5 ++- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs index f97a0d26..1e90f4da 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs @@ -1,24 +1,22 @@ -using Microsoft.AspNetCore.Http; -using ModelContextProtocol.AspNetCore.Auth; +using Microsoft.AspNetCore.Http; -namespace Microsoft.AspNetCore.Builder; +namespace ModelContextProtocol.AspNetCore.Auth; -public static partial class McpEndpointRouteBuilderExtensions +/// +/// Handles the resource metadata endpoint requests in an AOT-compatible way. +/// +internal sealed class ResourceMetadataEndpointHandler { - // This class handles the resource metadata endpoint in an AOT-compatible way - private sealed class ResourceMetadataEndpointHandler + private readonly ResourceMetadataService _resourceMetadataService; + + public ResourceMetadataEndpointHandler(ResourceMetadataService resourceMetadataService) { - private readonly ResourceMetadataService _resourceMetadataService; - - public ResourceMetadataEndpointHandler(ResourceMetadataService resourceMetadataService) - { - _resourceMetadataService = resourceMetadataService; - } - - public Task HandleRequest(HttpContext httpContext) - { - var result = _resourceMetadataService.HandleResourceMetadataRequest(httpContext); - return result.ExecuteAsync(httpContext); - } + _resourceMetadataService = resourceMetadataService; } -} + + public Task HandleRequest(HttpContext httpContext) + { + var result = _resourceMetadataService.HandleResourceMetadataRequest(httpContext); + return result.ExecuteAsync(httpContext); + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index 28e97445..b74348a2 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -13,7 +12,7 @@ namespace Microsoft.AspNetCore.Builder; /// /// Provides extension methods for to add MCP endpoints. /// -public static partial class McpEndpointRouteBuilderExtensions +public static class McpEndpointRouteBuilderExtensions { /// /// Sets up endpoints for handling MCP Streamable HTTP transport. From ec25187d5115b39d0fa27b819fe43bbb160b1d6f Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 00:44:09 -0700 Subject: [PATCH 019/128] Functionality consolidation --- .../Auth/OAuthAuthenticationService.cs | 59 ++++++++++++++-- .../Auth/OAuthAuthorizationHelpers.cs | 67 ++----------------- 2 files changed, 57 insertions(+), 69 deletions(-) diff --git a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs index 1e419220..005fa76a 100644 --- a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs +++ b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs @@ -116,6 +116,37 @@ public async Task HandleAuthenticationAsync( return tokenResponse; } + /// + /// Handles the exchange of an authorization code for an OAuth token. + /// + /// The token endpoint URI. + /// The client ID. + /// The client secret, if any. + /// The redirect URI used in the authorization request. + /// The authorization code received from the authorization server. + /// The PKCE code verifier. + /// A cancellation token to cancel the operation. + /// The OAuth token response. + public async Task HandleAuthorizationCodeAsync( + Uri tokenEndpoint, + string clientId, + string? clientSecret, + Uri redirectUri, + string authorizationCode, + string codeVerifier, + CancellationToken cancellationToken = default) + { + // Simply call our private implementation + return await ExchangeAuthorizationCodeForTokenAsync( + tokenEndpoint, + clientId, + clientSecret, + redirectUri, + authorizationCode, + codeVerifier, + cancellationToken); + } + private Uri? ExtractResourceMetadataUri(string wwwAuthenticateHeader) { if (string.IsNullOrEmpty(wwwAuthenticateHeader)) @@ -356,14 +387,25 @@ private string GenerateRandomString(int length) .Substring(0, length); } - // This method would be used in a real implementation after receiving the authorization code + /// + /// Exchanges an authorization code for an OAuth token. + /// + /// The token endpoint URI. + /// The client ID. + /// The client secret, if any. + /// The redirect URI used in the authorization request. + /// The authorization code received from the authorization server. + /// The PKCE code verifier. + /// A cancellation token to cancel the operation. + /// The OAuth token response. private async Task ExchangeAuthorizationCodeForTokenAsync( Uri tokenEndpoint, string clientId, string? clientSecret, Uri redirectUri, string authorizationCode, - string codeVerifier) + string codeVerifier, + CancellationToken cancellationToken = default) { var tokenRequest = new Dictionary { @@ -381,18 +423,21 @@ private async Task ExchangeAuthorizationCodeForTokenAsync( { // Add client authentication if secret is available var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue); - response = await _httpClient.PostAsync(tokenEndpoint, requestContent); - _httpClient.DefaultRequestHeaders.Authorization = null; + using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) + { + Content = requestContent + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authValue); + response = await _httpClient.SendAsync(request, cancellationToken); } else { - response = await _httpClient.PostAsync(tokenEndpoint, requestContent); + response = await _httpClient.PostAsync(tokenEndpoint, requestContent, cancellationToken); } response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(cancellationToken); var tokenResponse = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); if (tokenResponse == null) { diff --git a/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs b/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs index 7b344060..3c075d4b 100644 --- a/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs @@ -163,66 +163,6 @@ public static Func> CreateHttpListenerCallback( }; } - /// - /// Exchanges an authorization code for an OAuth token. - /// - /// The token endpoint URI. - /// The client ID. - /// The client secret, if any. - /// The redirect URI used in the authorization request. - /// The authorization code received from the authorization server. - /// The PKCE code verifier. - /// A cancellation token to cancel the operation. - /// The OAuth token response. - public static async Task ExchangeAuthorizationCodeForTokenAsync( - Uri tokenEndpoint, - string clientId, - string? clientSecret, - Uri redirectUri, - string authorizationCode, - string codeVerifier, - CancellationToken cancellationToken = default) - { - var tokenRequest = new Dictionary - { - ["grant_type"] = "authorization_code", - ["code"] = authorizationCode, - ["redirect_uri"] = redirectUri.ToString(), - ["client_id"] = clientId, - ["code_verifier"] = codeVerifier - }; - - var requestContent = new FormUrlEncodedContent(tokenRequest); - - HttpResponseMessage response; - if (!string.IsNullOrEmpty(clientSecret)) - { - // Add client authentication if secret is available - var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); - using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) - { - Content = requestContent - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authValue); - response = await _httpClient.SendAsync(request, cancellationToken); - } - else - { - response = await _httpClient.PostAsync(tokenEndpoint, requestContent, cancellationToken); - } - - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(cancellationToken); - var tokenResponse = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - if (tokenResponse == null) - { - throw new InvalidOperationException("Failed to parse token response."); - } - - return tokenResponse; - } - /// /// Creates a complete OAuth authorization code flow handler that automatically exchanges the code for a token. /// @@ -249,13 +189,16 @@ public static Func> CreateCompleteOAuthFlowHandler( { var codeHandler = CreateHttpListenerCallback(openBrowser, hostname, listenPort, redirectPath); + // Create an OAuth authentication service to handle token exchange + var authService = new OAuthAuthenticationService(); + return async (authorizationUri) => { // First get the authorization code string authorizationCode = await codeHandler(authorizationUri); - // Then exchange it for a token - return await ExchangeAuthorizationCodeForTokenAsync( + // Let the authentication service handle the token exchange + return await authService.HandleAuthorizationCodeAsync( tokenEndpoint, clientId, clientSecret, From a3926ee237b9aec4e86b947f0be47043231b3d32 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 00:45:54 -0700 Subject: [PATCH 020/128] Fix name --- ...{ProtectedMCPServerClient.csproj => ProtectedMCPClient.csproj} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename samples/ProtectedMCPClient/{ProtectedMCPServerClient.csproj => ProtectedMCPClient.csproj} (100%) diff --git a/samples/ProtectedMCPClient/ProtectedMCPServerClient.csproj b/samples/ProtectedMCPClient/ProtectedMCPClient.csproj similarity index 100% rename from samples/ProtectedMCPClient/ProtectedMCPServerClient.csproj rename to samples/ProtectedMCPClient/ProtectedMCPClient.csproj From 405db578221a288bedb77fe700ed7d4fc0d7001c Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 00:54:48 -0700 Subject: [PATCH 021/128] Organize things better --- .../Auth/ResourceMetadataService.cs | 2 +- .../HttpMcpServerBuilderExtensions.cs | 2 +- .../Auth/McpClientExtensions.cs | 1 + .../Auth/OAuthAuthenticationService.cs | 60 +++------------- .../Auth/OAuthAuthorizationHelpers.cs | 68 +++++++++++++++++-- .../Auth/OAuthDelegatingHandler.cs | 1 + .../{ => Types}/AuthorizationCodeOptions.cs | 2 +- .../Auth/{ => Types}/AuthorizationConfig.cs | 0 .../AuthorizationServerMetadata.cs | 0 .../{ => Types}/ClientRegistrationRequest.cs | 2 +- .../{ => Types}/ClientRegistrationResponse.cs | 0 .../OAuthToken.cs} | 2 +- .../{ => Types}/ProtectedResourceMetadata.cs | 2 +- .../Utils/Json/McpJsonUtilities.cs | 1 + 14 files changed, 80 insertions(+), 63 deletions(-) rename src/ModelContextProtocol/Auth/{ => Types}/AuthorizationCodeOptions.cs (96%) rename src/ModelContextProtocol/Auth/{ => Types}/AuthorizationConfig.cs (100%) rename src/ModelContextProtocol/Auth/{ => Types}/AuthorizationServerMetadata.cs (100%) rename src/ModelContextProtocol/Auth/{ => Types}/ClientRegistrationRequest.cs (98%) rename src/ModelContextProtocol/Auth/{ => Types}/ClientRegistrationResponse.cs (100%) rename src/ModelContextProtocol/Auth/{OAuthTokenResponse.cs => Types/OAuthToken.cs} (96%) rename src/ModelContextProtocol/Auth/{ => Types}/ProtectedResourceMetadata.cs (96%) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataService.cs b/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataService.cs index aeb967da..37336187 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataService.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataService.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.Http; -using ModelContextProtocol.Auth; +using ModelContextProtocol.Auth.Types; using ModelContextProtocol.Utils.Json; namespace ModelContextProtocol.AspNetCore.Auth; diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 8e021899..53fec786 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using ModelContextProtocol.AspNetCore; using ModelContextProtocol.AspNetCore.Auth; -using ModelContextProtocol.Auth; +using ModelContextProtocol.Auth.Types; using ModelContextProtocol.Server; namespace Microsoft.Extensions.DependencyInjection; diff --git a/src/ModelContextProtocol/Auth/McpClientExtensions.cs b/src/ModelContextProtocol/Auth/McpClientExtensions.cs index b484c5c6..68a2b8ef 100644 --- a/src/ModelContextProtocol/Auth/McpClientExtensions.cs +++ b/src/ModelContextProtocol/Auth/McpClientExtensions.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using System.Collections.Concurrent; +using ModelContextProtocol.Auth.Types; namespace ModelContextProtocol.Auth; diff --git a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs index 005fa76a..b94d5444 100644 --- a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs +++ b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using ModelContextProtocol.Auth.Types; using ModelContextProtocol.Utils.Json; namespace ModelContextProtocol.Auth; @@ -116,37 +117,6 @@ public async Task HandleAuthenticationAsync( return tokenResponse; } - /// - /// Handles the exchange of an authorization code for an OAuth token. - /// - /// The token endpoint URI. - /// The client ID. - /// The client secret, if any. - /// The redirect URI used in the authorization request. - /// The authorization code received from the authorization server. - /// The PKCE code verifier. - /// A cancellation token to cancel the operation. - /// The OAuth token response. - public async Task HandleAuthorizationCodeAsync( - Uri tokenEndpoint, - string clientId, - string? clientSecret, - Uri redirectUri, - string authorizationCode, - string codeVerifier, - CancellationToken cancellationToken = default) - { - // Simply call our private implementation - return await ExchangeAuthorizationCodeForTokenAsync( - tokenEndpoint, - clientId, - clientSecret, - redirectUri, - authorizationCode, - codeVerifier, - cancellationToken); - } - private Uri? ExtractResourceMetadataUri(string wwwAuthenticateHeader) { if (string.IsNullOrEmpty(wwwAuthenticateHeader)) @@ -387,25 +357,14 @@ private string GenerateRandomString(int length) .Substring(0, length); } - /// - /// Exchanges an authorization code for an OAuth token. - /// - /// The token endpoint URI. - /// The client ID. - /// The client secret, if any. - /// The redirect URI used in the authorization request. - /// The authorization code received from the authorization server. - /// The PKCE code verifier. - /// A cancellation token to cancel the operation. - /// The OAuth token response. + // This method would be used in a real implementation after receiving the authorization code private async Task ExchangeAuthorizationCodeForTokenAsync( Uri tokenEndpoint, string clientId, string? clientSecret, Uri redirectUri, string authorizationCode, - string codeVerifier, - CancellationToken cancellationToken = default) + string codeVerifier) { var tokenRequest = new Dictionary { @@ -423,21 +382,18 @@ private async Task ExchangeAuthorizationCodeForTokenAsync( { // Add client authentication if secret is available var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); - using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) - { - Content = requestContent - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authValue); - response = await _httpClient.SendAsync(request, cancellationToken); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue); + response = await _httpClient.PostAsync(tokenEndpoint, requestContent); + _httpClient.DefaultRequestHeaders.Authorization = null; } else { - response = await _httpClient.PostAsync(tokenEndpoint, requestContent, cancellationToken); + response = await _httpClient.PostAsync(tokenEndpoint, requestContent); } response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(cancellationToken); + var json = await response.Content.ReadAsStringAsync(); var tokenResponse = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); if (tokenResponse == null) { diff --git a/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs b/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs index 3c075d4b..ba7c65b5 100644 --- a/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using ModelContextProtocol.Auth.Types; using ModelContextProtocol.Utils.Json; namespace ModelContextProtocol.Auth; @@ -163,6 +164,66 @@ public static Func> CreateHttpListenerCallback( }; } + /// + /// Exchanges an authorization code for an OAuth token. + /// + /// The token endpoint URI. + /// The client ID. + /// The client secret, if any. + /// The redirect URI used in the authorization request. + /// The authorization code received from the authorization server. + /// The PKCE code verifier. + /// A cancellation token to cancel the operation. + /// The OAuth token response. + public static async Task ExchangeAuthorizationCodeForTokenAsync( + Uri tokenEndpoint, + string clientId, + string? clientSecret, + Uri redirectUri, + string authorizationCode, + string codeVerifier, + CancellationToken cancellationToken = default) + { + var tokenRequest = new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = authorizationCode, + ["redirect_uri"] = redirectUri.ToString(), + ["client_id"] = clientId, + ["code_verifier"] = codeVerifier + }; + + var requestContent = new FormUrlEncodedContent(tokenRequest); + + HttpResponseMessage response; + if (!string.IsNullOrEmpty(clientSecret)) + { + // Add client authentication if secret is available + var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); + using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) + { + Content = requestContent + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authValue); + response = await _httpClient.SendAsync(request, cancellationToken); + } + else + { + response = await _httpClient.PostAsync(tokenEndpoint, requestContent, cancellationToken); + } + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var tokenResponse = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + if (tokenResponse == null) + { + throw new InvalidOperationException("Failed to parse token response."); + } + + return tokenResponse; + } + /// /// Creates a complete OAuth authorization code flow handler that automatically exchanges the code for a token. /// @@ -189,16 +250,13 @@ public static Func> CreateCompleteOAuthFlowHandler( { var codeHandler = CreateHttpListenerCallback(openBrowser, hostname, listenPort, redirectPath); - // Create an OAuth authentication service to handle token exchange - var authService = new OAuthAuthenticationService(); - return async (authorizationUri) => { // First get the authorization code string authorizationCode = await codeHandler(authorizationUri); - // Let the authentication service handle the token exchange - return await authService.HandleAuthorizationCodeAsync( + // Then exchange it for a token + return await ExchangeAuthorizationCodeForTokenAsync( tokenEndpoint, clientId, clientSecret, diff --git a/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs b/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs index 96bfa728..e37d0313 100644 --- a/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs +++ b/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Auth.Types; using System.Net; using System.Net.Http.Headers; diff --git a/src/ModelContextProtocol/Auth/AuthorizationCodeOptions.cs b/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs similarity index 96% rename from src/ModelContextProtocol/Auth/AuthorizationCodeOptions.cs rename to src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs index bc93621a..67ee1154 100644 --- a/src/ModelContextProtocol/Auth/AuthorizationCodeOptions.cs +++ b/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs @@ -1,4 +1,4 @@ -namespace ModelContextProtocol.Auth; +namespace ModelContextProtocol.Auth.Types; /// /// Configuration options for the authorization code flow. diff --git a/src/ModelContextProtocol/Auth/AuthorizationConfig.cs b/src/ModelContextProtocol/Auth/Types/AuthorizationConfig.cs similarity index 100% rename from src/ModelContextProtocol/Auth/AuthorizationConfig.cs rename to src/ModelContextProtocol/Auth/Types/AuthorizationConfig.cs diff --git a/src/ModelContextProtocol/Auth/AuthorizationServerMetadata.cs b/src/ModelContextProtocol/Auth/Types/AuthorizationServerMetadata.cs similarity index 100% rename from src/ModelContextProtocol/Auth/AuthorizationServerMetadata.cs rename to src/ModelContextProtocol/Auth/Types/AuthorizationServerMetadata.cs diff --git a/src/ModelContextProtocol/Auth/ClientRegistrationRequest.cs b/src/ModelContextProtocol/Auth/Types/ClientRegistrationRequest.cs similarity index 98% rename from src/ModelContextProtocol/Auth/ClientRegistrationRequest.cs rename to src/ModelContextProtocol/Auth/Types/ClientRegistrationRequest.cs index f73e934c..98d34444 100644 --- a/src/ModelContextProtocol/Auth/ClientRegistrationRequest.cs +++ b/src/ModelContextProtocol/Auth/Types/ClientRegistrationRequest.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ModelContextProtocol.Auth; +namespace ModelContextProtocol.Auth.Types; /// /// Represents the client registration request metadata. diff --git a/src/ModelContextProtocol/Auth/ClientRegistrationResponse.cs b/src/ModelContextProtocol/Auth/Types/ClientRegistrationResponse.cs similarity index 100% rename from src/ModelContextProtocol/Auth/ClientRegistrationResponse.cs rename to src/ModelContextProtocol/Auth/Types/ClientRegistrationResponse.cs diff --git a/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs b/src/ModelContextProtocol/Auth/Types/OAuthToken.cs similarity index 96% rename from src/ModelContextProtocol/Auth/OAuthTokenResponse.cs rename to src/ModelContextProtocol/Auth/Types/OAuthToken.cs index e96c447d..0bcdb67a 100644 --- a/src/ModelContextProtocol/Auth/OAuthTokenResponse.cs +++ b/src/ModelContextProtocol/Auth/Types/OAuthToken.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ModelContextProtocol.Auth; +namespace ModelContextProtocol.Auth.Types; /// /// Represents an OAuth token response. diff --git a/src/ModelContextProtocol/Auth/ProtectedResourceMetadata.cs b/src/ModelContextProtocol/Auth/Types/ProtectedResourceMetadata.cs similarity index 96% rename from src/ModelContextProtocol/Auth/ProtectedResourceMetadata.cs rename to src/ModelContextProtocol/Auth/Types/ProtectedResourceMetadata.cs index ed92a05c..2a1ba160 100644 --- a/src/ModelContextProtocol/Auth/ProtectedResourceMetadata.cs +++ b/src/ModelContextProtocol/Auth/Types/ProtectedResourceMetadata.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ModelContextProtocol.Auth; +namespace ModelContextProtocol.Auth.Types; /// /// Represents the resource metadata for OAuth authorization. diff --git a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs index 34a8beda..88c9048f 100644 --- a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs +++ b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.AI; using ModelContextProtocol.Auth; +using ModelContextProtocol.Auth.Types; using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Protocol.Types; using System.Diagnostics.CodeAnalysis; From a0486d3d68cc704805a077bae7b735bb06c8cfd4 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 10:02:22 -0700 Subject: [PATCH 022/128] Cleanup --- samples/ProtectedMCPServer/Program.cs | 3 +-- .../Auth/McpAuthenticationResponseMarker.cs | 6 ++++++ .../Auth/McpAuthorizationMarker.cs | 6 ++++++ .../HttpMcpServerBuilderExtensions.cs | 10 ---------- 4 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMarker.cs create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationMarker.cs diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 56063a70..804abee9 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -1,8 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; -using ModelContextProtocol.AspNetCore; -using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.AspNetCore.Auth; +using ModelContextProtocol.Protocol.Types; using System.Security.Claims; using System.Text.Encodings.Web; diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMarker.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMarker.cs new file mode 100644 index 00000000..a5753e93 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMarker.cs @@ -0,0 +1,6 @@ +namespace ModelContextProtocol.AspNetCore.Auth; + +/// +/// Marker class to indicate that MCP authentication response middleware should be used. +/// +internal class McpAuthenticationResponseMarker { } diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationMarker.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationMarker.cs new file mode 100644 index 00000000..12bf3961 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationMarker.cs @@ -0,0 +1,6 @@ +namespace ModelContextProtocol.AspNetCore.Auth; + +/// +/// Marker class to indicate that MCP authorization has been configured. +/// +internal class McpAuthorizationMarker { } diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 53fec786..0e4fda43 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -94,13 +94,3 @@ public static IMcpServerBuilder WithAuthorization( return builder; } } - -/// -/// Marker class to indicate that MCP authorization has been configured. -/// -internal class McpAuthorizationMarker { } - -/// -/// Marker class to indicate that MCP authentication response middleware should be used. -/// -internal class McpAuthenticationResponseMarker { } From cc2f5709b12db589416d7ecafc183831b74f95a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 10:54:37 -0700 Subject: [PATCH 023/128] Update samples/ProtectedMCPServer/Program.cs Co-authored-by: Stephen Halter --- samples/ProtectedMCPServer/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 804abee9..11281399 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -119,7 +119,7 @@ Console.WriteLine(); Console.WriteLine("Press Ctrl+C to stop the server"); -await app.RunAsync(); +app.Run("http://localhost:7071/"); // Simple auth handler that accepts any non-empty token for testing // In a real app, you'd use a JWT handler or other proper authentication From 4e0b508d713e94ead76bed2a740aa9992c469adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 10:54:44 -0700 Subject: [PATCH 024/128] Update samples/ProtectedMCPServer/Program.cs Co-authored-by: Stephen Halter --- samples/ProtectedMCPServer/Program.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 11281399..827d9d8e 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -103,9 +103,6 @@ // Map MCP endpoints with authorization app.MapMcp(); -// Configure the server URL -app.Urls.Add("http://localhost:7071"); - Console.WriteLine("Starting MCP server with authorization at http://localhost:7071"); Console.WriteLine("PRM Document URL: http://localhost:7071/.well-known/oauth-protected-resource"); From 32f7b16e8bfd23e01774da3819a20eca71c10bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 10:55:19 -0700 Subject: [PATCH 025/128] Update src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs Co-authored-by: Stephen Halter --- .../Auth/McpAuthorizationExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs index 729a8ea1..710e6c33 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -6,7 +6,7 @@ namespace ModelContextProtocol.AspNetCore.Auth; /// /// Extension methods for adding MCP authorization support to ASP.NET Core applications. /// -public static class McpAuthorizationExtensions +public static class McpAuthenticationExtensions { /// /// Adds MCP authorization support to the application. From b5728d0b96e8cb2c345161cb434a1994a2553c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 11:05:52 -0700 Subject: [PATCH 026/128] Update samples/AspNetCoreSseServer/Program.cs Co-authored-by: Stephen Halter --- samples/AspNetCoreSseServer/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/AspNetCoreSseServer/Program.cs b/samples/AspNetCoreSseServer/Program.cs index aade7051..41f98ee5 100644 --- a/samples/AspNetCoreSseServer/Program.cs +++ b/samples/AspNetCoreSseServer/Program.cs @@ -2,7 +2,6 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Trace; using OpenTelemetry; -using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() From 487bbd75b748fe54ae0eff8cbe2b7dfefe986283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 11:06:04 -0700 Subject: [PATCH 027/128] Update src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs Co-authored-by: Stephen Halter --- .../Auth/McpAuthorizationExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs index 710e6c33..8858ef78 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection.Extensions; -namespace ModelContextProtocol.AspNetCore.Auth; +namespace Microsoft.Extensions.DependencyInjection; /// /// Extension methods for adding MCP authorization support to ASP.NET Core applications. From 22b7bcc7d2274a462af4fa043b5cb401b896d891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 11:12:32 -0700 Subject: [PATCH 028/128] Update src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs Co-authored-by: Stephen Halter --- .../Auth/McpAuthorizationExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs index 8858ef78..7a427ec2 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -14,7 +14,7 @@ public static class McpAuthenticationExtensions /// The authentication builder. /// An action to configure MCP authentication options. /// The authentication builder for chaining. - public static AuthenticationBuilder AddMcpAuthorization( + public static AuthenticationBuilder AddMcp( this AuthenticationBuilder builder, Action? configureOptions = null) { From a433f5a3de91d741d25bffa756c2210643e55f67 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 11:26:36 -0700 Subject: [PATCH 029/128] Contextual rename --- .../Auth/McpAuthorizationExtensions.cs | 1 + .../HttpMcpServerBuilderExtensions.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs index 7a427ec2..7c0d3cac 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection.Extensions; +using ModelContextProtocol.AspNetCore.Auth; namespace Microsoft.Extensions.DependencyInjection; diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 0e4fda43..8a7f640b 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -69,7 +69,7 @@ public static IMcpServerBuilder WithAuthorization( // Add authentication with the MCP authentication handler builder.Services.AddAuthentication() - .AddMcpAuthorization(options => + .AddMcp(options => { // Default to the standard OAuth protected resource endpoint options.ResourceMetadataUri = new Uri("/.well-known/oauth-protected-resource", UriKind.Relative); From 31f8ce9b912a2f5eff427e8c6d651cad55c9be92 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 11:38:56 -0700 Subject: [PATCH 030/128] Added overload for custom scheme --- .../Auth/McpAuthorizationExtensions.cs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs index 7c0d3cac..344cfbaa 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -18,12 +18,29 @@ public static class McpAuthenticationExtensions public static AuthenticationBuilder AddMcp( this AuthenticationBuilder builder, Action? configureOptions = null) + { + return AddMcp(builder, "McpAuth", "MCP Authentication", configureOptions); + } + + /// + /// Adds MCP authorization support to the application with a custom scheme name. + /// + /// The authentication builder. + /// The authentication scheme name to use. + /// The display name for the authentication scheme. + /// An action to configure MCP authentication options. + /// The authentication builder for chaining. + public static AuthenticationBuilder AddMcp( + this AuthenticationBuilder builder, + string authenticationScheme, + string displayName, + Action? configureOptions = null) { builder.Services.TryAddSingleton(); return builder.AddScheme( - "McpAuth", - "MCP Authentication", + authenticationScheme, + displayName, configureOptions ?? (options => { })); } } From 48bff9cb523b6f1ba851d77c61e772b68832eb41 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 11:41:22 -0700 Subject: [PATCH 031/128] Introduce proper constants --- .../Auth/McpAuthenticationDefaults.cs | 17 +++++++++++++++++ .../Auth/McpAuthorizationExtensions.cs | 6 +++++- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationDefaults.cs diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationDefaults.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationDefaults.cs new file mode 100644 index 00000000..5b5437bb --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationDefaults.cs @@ -0,0 +1,17 @@ +namespace ModelContextProtocol.AspNetCore.Auth; + +/// +/// Default values used by MCP authentication. +/// +public static class McpAuthenticationDefaults +{ + /// + /// The default value used for authentication scheme name. + /// + public const string AuthenticationScheme = "McpAuth"; + + /// + /// The default value used for authentication scheme display name. + /// + public const string DisplayName = "MCP Authentication"; +} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs index 344cfbaa..db9f19c6 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -19,7 +19,11 @@ public static AuthenticationBuilder AddMcp( this AuthenticationBuilder builder, Action? configureOptions = null) { - return AddMcp(builder, "McpAuth", "MCP Authentication", configureOptions); + return AddMcp( + builder, + McpAuthenticationDefaults.AuthenticationScheme, + McpAuthenticationDefaults.DisplayName, + configureOptions); } /// From 991b29b0e2a29c1756d1dd29840ce3953a6ab5e0 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 13:40:33 -0700 Subject: [PATCH 032/128] Simplify the handling of the WWW-Authenticate implementaiton --- samples/ProtectedMCPServer/Program.cs | 38 ++++++------- .../Auth/McpAuthenticationHandler.cs | 36 ++++++++++-- .../McpAuthenticationResponseMiddleware.cs | 57 ------------------- ...henticationResponseMiddlewareExtensions.cs | 19 ------- .../Auth/McpWebApplicationExtensions.cs | 12 ++-- .../McpEndpointRouteBuilderExtensions.cs | 12 ---- 6 files changed, 56 insertions(+), 118 deletions(-) delete mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddleware.cs delete mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddlewareExtensions.cs diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 827d9d8e..35ca92f9 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -76,23 +76,23 @@ metadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); }); -// Configure authentication using the built-in authentication system -builder.Services.AddAuthentication(options => -{ - options.DefaultScheme = "Bearer"; - options.DefaultChallengeScheme = "Bearer"; // Ensure challenges use Bearer scheme -}) -.AddScheme("Bearer", options => { }); - -// Add authorization policy for MCP -builder.Services.AddAuthorization(options => -{ - options.AddPolicy("McpAuth", policy => - { - policy.RequireAuthenticatedUser(); - policy.RequireClaim("scope", "weather.read"); - }); -}); +// // Configure authentication using the built-in authentication system +// builder.Services.AddAuthentication(options => +// { +// options.DefaultScheme = "Bearer"; +// options.DefaultChallengeScheme = "Bearer"; // Ensure challenges use Bearer scheme +// }) +// .AddScheme("Bearer", options => { }); + +//// Add authorization policy for MCP +//builder.Services.AddAuthorization(options => +//{ +// options.AddPolicy("McpAuth", policy => +// { +// policy.RequireAuthenticatedUser(); +// policy.RequireClaim("scope", "weather.read"); +// }); +//}); var app = builder.Build(); @@ -173,8 +173,6 @@ protected override Task HandleAuthenticateAsync() protected override Task HandleChallengeAsync(AuthenticationProperties properties) { - // No need to manually set WWW-Authenticate header anymore - handled by middleware - Response.StatusCode = 401; - return Task.CompletedTask; + return base.HandleChallengeAsync(properties); } } diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index 42bf0f90..97fe47f0 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using System; using System.Text.Encodings.Web; +using System.Threading.Tasks; namespace ModelContextProtocol.AspNetCore.Auth; @@ -10,15 +12,19 @@ namespace ModelContextProtocol.AspNetCore.Auth; /// public class McpAuthenticationHandler : AuthenticationHandler { + private readonly ResourceMetadataService _resourceMetadataService; + /// /// Initializes a new instance of the class. /// public McpAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, - UrlEncoder encoder) + UrlEncoder encoder, + ResourceMetadataService resourceMetadataService) : base(options, logger, encoder) { + _resourceMetadataService = resourceMetadataService; } /// @@ -32,12 +38,30 @@ protected override Task HandleAuthenticateAsync() /// protected override Task HandleChallengeAsync(AuthenticationProperties properties) { - if (Options.ResourceMetadataUri != null) - { - Response.Headers.WWWAuthenticate = $"Bearer resource_metadata=\"{Options.ResourceMetadataUri}\""; - } + // Set the response status code + Response.StatusCode = 401; // Unauthorized + + // Generate the full resource metadata URL based on the current request + var baseUrl = $"{Request.Scheme}://{Request.Host}"; + var metadataPath = Options.ResourceMetadataUri?.ToString() ?? "/.well-known/oauth-protected-resource"; + var metadataUrl = metadataPath.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? metadataPath + : $"{baseUrl}{metadataPath}"; + + // Initialize properties if null + properties ??= new AuthenticationProperties(); + + // Set the WWW-Authenticate header with the resource_metadata + string headerValue = $"Bearer realm=\"{Scheme.Name}\""; + headerValue += $", resource_metadata=\"{metadataUrl}\""; + + // Use Headers.Append with a StringValues object + Response.Headers["WWW-Authenticate"] = headerValue; + + // Store the resource_metadata in properties in case other handlers need it + properties.Items["resource_metadata"] = metadataUrl; - return Task.CompletedTask; + return base.HandleChallengeAsync(properties); } } diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddleware.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddleware.cs deleted file mode 100644 index ff30e4bc..00000000 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddleware.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using ModelContextProtocol.Auth; - -namespace ModelContextProtocol.AspNetCore.Auth; - -/// -/// Middleware that attaches WWW-Authenticate headers with resource metadata to 401 responses. -/// -public class McpAuthenticationResponseMiddleware -{ - private readonly RequestDelegate _next; - private readonly ResourceMetadataService _resourceMetadataService; - - /// - /// Initializes a new instance of the class. - /// - /// The next request delegate in the pipeline. - /// The resource metadata service. - public McpAuthenticationResponseMiddleware(RequestDelegate next, ResourceMetadataService resourceMetadataService) - { - _next = next; - _resourceMetadataService = resourceMetadataService; - } - - /// - /// Processes the request and adds WWW-Authenticate headers to 401 responses. - /// - /// The HTTP context. - /// A task representing the asynchronous operation. - public async Task InvokeAsync(HttpContext context) - { - // Add a callback to the OnStarting event which fires before the response headers are sent - context.Response.OnStarting(() => - { - // Check if the response is a 401 Unauthorized - if (context.Response.StatusCode == StatusCodes.Status401Unauthorized) - { - // Get the base URL of the request - var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}"; - var metadataPath = "/.well-known/oauth-protected-resource"; - var metadataUrl = $"{baseUrl}{metadataPath}"; - - // Add or update the WWW-Authenticate header - if (!context.Response.Headers.ContainsKey("WWW-Authenticate") || - !context.Response.Headers["WWW-Authenticate"].ToString().Contains("resource_metadata")) - { - context.Response.Headers.WWWAuthenticate = $"Bearer resource_metadata=\"{metadataUrl}\""; - } - } - return Task.CompletedTask; - }); - - // Call the next delegate/middleware in the pipeline - await _next(context); - } -} diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddlewareExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddlewareExtensions.cs deleted file mode 100644 index 8dc2bcd4..00000000 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMiddlewareExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.AspNetCore.Builder; - -namespace ModelContextProtocol.AspNetCore.Auth; - -/// -/// Extension methods for the McpAuthenticationResponseMiddleware. -/// -public static class McpAuthenticationResponseMiddlewareExtensions -{ - /// - /// Adds the MCP authentication response middleware to the application pipeline. - /// - /// The application builder. - /// The application builder for chaining. - public static IApplicationBuilder UseWwwAuthenticateHeaderMiddleware(this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } -} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpWebApplicationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpWebApplicationExtensions.cs index 5d8adcd0..4686e6cd 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpWebApplicationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpWebApplicationExtensions.cs @@ -9,11 +9,13 @@ namespace ModelContextProtocol.AspNetCore.Auth; public static class McpWebApplicationExtensions { /// - /// Adds the MCP authentication response middleware to the application pipeline. - /// This middleware automatically adds the resource_metadata field to WWW-Authenticate headers in 401 responses. + /// This method maintains compatibility with existing code that calls UseMcpAuthenticationResponse. + /// The actual middleware functionality is now handled by the McpAuthenticationHandler as part of + /// the standard ASP.NET Core authentication pipeline. /// /// - /// This middleware should be registered AFTER UseAuthentication() but BEFORE UseAuthorization(). + /// While this method is still required for backward compatibility, its functionality + /// is now fully implemented by the McpAuthenticationHandler. /// /// The web application. /// The web application for chaining. @@ -26,6 +28,8 @@ public static IApplicationBuilder UseMcpAuthenticationResponse(this IApplication "Make sure you call AddMcpServer().WithAuthorization() first."); } - return app.UseMiddleware(); + // Return the app directly without adding middleware, as the functionality + // is now provided by the McpAuthenticationHandler + return app; } } \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index b74348a2..05220af8 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -55,18 +55,6 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo var authMarker = endpoints.ServiceProvider.GetService(); if (authMarker != null) { - // Check if the authentication response middleware is configured - var authResponseMarker = endpoints.ServiceProvider.GetService(); - if (authResponseMarker != null) - { - // Register the middleware to automatically add WWW-Authenticate headers to 401 responses - // We need to add this middleware to the parent app, not just the endpoint group - if (endpoints is IApplicationBuilder app) - { - app.UseWwwAuthenticateHeaderMiddleware(); - } - } - // Authorization is configured, so automatically map the OAuth protected resource endpoint var resourceMetadataService = endpoints.ServiceProvider.GetRequiredService(); From 5c28b6217c20ddf9116857040906482b049db014 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 13:44:21 -0700 Subject: [PATCH 033/128] Update Program.cs --- samples/ProtectedMCPServer/Program.cs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 35ca92f9..d6710a71 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -76,24 +76,6 @@ metadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); }); -// // Configure authentication using the built-in authentication system -// builder.Services.AddAuthentication(options => -// { -// options.DefaultScheme = "Bearer"; -// options.DefaultChallengeScheme = "Bearer"; // Ensure challenges use Bearer scheme -// }) -// .AddScheme("Bearer", options => { }); - -//// Add authorization policy for MCP -//builder.Services.AddAuthorization(options => -//{ -// options.AddPolicy("McpAuth", policy => -// { -// policy.RequireAuthenticatedUser(); -// policy.RequireClaim("scope", "weather.read"); -// }); -//}); - var app = builder.Build(); app.UseAuthentication(); From 1a1e48ca1067149a4af1ac45c8e4f5094e517b72 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 13:48:35 -0700 Subject: [PATCH 034/128] Remove no longer used entity --- samples/ProtectedMCPServer/Program.cs | 2 -- .../Auth/McpWebApplicationExtensions.cs | 35 ------------------- 2 files changed, 37 deletions(-) delete mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpWebApplicationExtensions.cs diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index d6710a71..11be3a06 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; -using ModelContextProtocol.AspNetCore.Auth; using ModelContextProtocol.Protocol.Types; using System.Security.Claims; using System.Text.Encodings.Web; @@ -79,7 +78,6 @@ var app = builder.Build(); app.UseAuthentication(); -app.UseMcpAuthenticationResponse(); app.UseAuthorization(); // Map MCP endpoints with authorization diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpWebApplicationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpWebApplicationExtensions.cs deleted file mode 100644 index 4686e6cd..00000000 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpWebApplicationExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; - -namespace ModelContextProtocol.AspNetCore.Auth; - -/// -/// Extension methods for WebApplication to add MCP-specific middleware. -/// -public static class McpWebApplicationExtensions -{ - /// - /// This method maintains compatibility with existing code that calls UseMcpAuthenticationResponse. - /// The actual middleware functionality is now handled by the McpAuthenticationHandler as part of - /// the standard ASP.NET Core authentication pipeline. - /// - /// - /// While this method is still required for backward compatibility, its functionality - /// is now fully implemented by the McpAuthenticationHandler. - /// - /// The web application. - /// The web application for chaining. - public static IApplicationBuilder UseMcpAuthenticationResponse(this IApplicationBuilder app) - { - if (app.ApplicationServices.GetService() == null) - { - throw new InvalidOperationException( - "McpAuthenticationResponseMarker service is not registered. " + - "Make sure you call AddMcpServer().WithAuthorization() first."); - } - - // Return the app directly without adding middleware, as the functionality - // is now provided by the McpAuthenticationHandler - return app; - } -} \ No newline at end of file From 008f5213b19f5fd6542490179182b1542669c2b4 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 13:49:04 -0700 Subject: [PATCH 035/128] Move to its own file. --- .../Auth/McpAuthenticationHandler.cs | 11 ----------- .../Auth/McpAuthenticationOptions.cs | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index 97fe47f0..ecdea8b4 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -64,14 +64,3 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties return base.HandleChallengeAsync(properties); } } - -/// -/// Options for the MCP authentication handler. -/// -public class McpAuthenticationOptions : AuthenticationSchemeOptions -{ - /// - /// The URI to the resource metadata document. - /// - public Uri? ResourceMetadataUri { get; set; } -} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs new file mode 100644 index 00000000..956614e9 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authentication; + +namespace ModelContextProtocol.AspNetCore.Auth; + +/// +/// Options for the MCP authentication handler. +/// +public class McpAuthenticationOptions : AuthenticationSchemeOptions +{ + /// + /// The URI to the resource metadata document. + /// + public Uri? ResourceMetadataUri { get; set; } +} \ No newline at end of file From 6e23f4aba1f8efa9095c1567b66810f5dadd34e2 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 14:29:27 -0700 Subject: [PATCH 036/128] Placeholder setup --- samples/ProtectedMCPServer/Program.cs | 52 ++++++++++++----- .../Auth/McpAuthenticationHandler.cs | 16 ++++- .../Auth/McpAuthenticationOptions.cs | 13 +++++ .../HttpMcpServerBuilderExtensions.cs | 58 ------------------- .../McpEndpointRouteBuilderExtensions.cs | 2 +- 5 files changed, 67 insertions(+), 74 deletions(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 11be3a06..41e02ae0 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -1,11 +1,46 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; +using ModelContextProtocol.AspNetCore.Auth; using ModelContextProtocol.Protocol.Types; using System.Security.Claims; using System.Text.Encodings.Web; var builder = WebApplication.CreateBuilder(args); +// Configure authentication to use MCP for challenges +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = "Bearer"; + options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme; // Use MCP for challenges +}) +.AddScheme("Bearer", options => { }) +.AddMcp(options => { + // Configure MCP authentication options with the resource metadata URI + options.ResourceMetadataUri = new Uri("/.well-known/oauth-protected-resource", UriKind.Relative); + + // Configure the resource metadata using our enhanced options + options.ResourceMetadata.Resource = new Uri("http://localhost:7071"); + options.ResourceMetadata.AuthorizationServers.Add(new Uri("https://login.microsoftonline.com/a2213e1c-e51e-4304-9a0d-effe57f31655/v2.0")); + options.ResourceMetadata.BearerMethodsSupported.Add("header"); + options.ResourceMetadata.ScopesSupported.AddRange(["weather.read", "weather.write"]); + options.ResourceMetadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); +}); + +// Add authorization services +builder.Services.AddAuthorization(options => +{ + options.AddPolicy(McpAuthenticationDefaults.AuthenticationScheme, policy => + { + policy.RequireAuthenticatedUser(); + }); +}); + +// Don't forget to register the ResourceMetadataService +builder.Services.AddSingleton(); + +// IMPORTANT: Register the McpAuthorizationMarker to enable authorization on MCP endpoints +builder.Services.AddSingleton(); + // Configure MCP Server builder.Services.AddMcpServer(options => { @@ -64,16 +99,7 @@ } }; }) -.WithHttpTransport() -.WithAuthorization(metadata => -{ - metadata.AuthorizationServers.Add(new Uri("https://login.microsoftonline.com/a2213e1c-e51e-4304-9a0d-effe57f31655/v2.0")); - metadata.BearerMethodsSupported.Add("header"); - metadata.ScopesSupported.AddRange(["weather.read", "weather.write"]); - - // Add optional documentation - metadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); -}); +.WithHttpTransport(); var app = builder.Build(); @@ -151,8 +177,6 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(AuthenticateResult.Success(ticket)); } - protected override Task HandleChallengeAsync(AuthenticationProperties properties) - { - return base.HandleChallengeAsync(properties); - } + // The MCP authentication handler will handle challenges + // so we don't need to implement HandleChallengeAsync here } diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index ecdea8b4..9c944223 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -51,11 +51,25 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // Initialize properties if null properties ??= new AuthenticationProperties(); + // Set up the resource URI if not already configured, using the current request as a fallback + if (Options.ResourceMetadata.Resource == null) + { + Options.ResourceMetadata.Resource = new Uri($"{Request.Scheme}://{Request.Host}"); + } + + // Configure the resource metadata service with our metadata + _resourceMetadataService.ConfigureMetadata(metadata => { + metadata.Resource = Options.ResourceMetadata.Resource; + metadata.AuthorizationServers = Options.ResourceMetadata.AuthorizationServers; + metadata.BearerMethodsSupported = Options.ResourceMetadata.BearerMethodsSupported; + metadata.ScopesSupported = Options.ResourceMetadata.ScopesSupported; + metadata.ResourceDocumentation = Options.ResourceMetadata.ResourceDocumentation; + }); + // Set the WWW-Authenticate header with the resource_metadata string headerValue = $"Bearer realm=\"{Scheme.Name}\""; headerValue += $", resource_metadata=\"{metadataUrl}\""; - // Use Headers.Append with a StringValues object Response.Headers["WWW-Authenticate"] = headerValue; // Store the resource_metadata in properties in case other handlers need it diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs index 956614e9..d872d754 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authentication; +using ModelContextProtocol.Auth.Types; namespace ModelContextProtocol.AspNetCore.Auth; @@ -10,5 +11,17 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions /// /// The URI to the resource metadata document. /// + /// + /// This URI will be included in the WWW-Authenticate header when a 401 response is returned. + /// public Uri? ResourceMetadataUri { get; set; } + + /// + /// Gets or sets the protected resource metadata. + /// + /// + /// This contains the OAuth metadata for the protected resource, including authorization servers, + /// supported scopes, and other information needed for clients to authenticate. + /// + public ProtectedResourceMetadata ResourceMetadata { get; set; } = new ProtectedResourceMetadata(); } \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 8a7f640b..39ba45e4 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using ModelContextProtocol.AspNetCore; using ModelContextProtocol.AspNetCore.Auth; -using ModelContextProtocol.Auth.Types; using ModelContextProtocol.Server; namespace Microsoft.Extensions.DependencyInjection; @@ -36,61 +35,4 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder return builder; } - - /// - /// Adds OAuth authorization support to the MCP server. - /// - /// The builder instance. - /// An action to configure the resource metadata. - /// An action to configure authentication options. - /// The builder provided in . - /// is . - public static IMcpServerBuilder WithAuthorization( - this IMcpServerBuilder builder, - Action? configureMetadata = null, - Action? configureOptions = null) - { - ArgumentNullException.ThrowIfNull(builder); - - // Create and register the resource metadata service - var resourceMetadataService = new ResourceMetadataService(); - - // Apply configuration directly to the instance - if (configureMetadata != null) - { - resourceMetadataService.ConfigureMetadata(configureMetadata); - } - - // Register the configured instance as a singleton - builder.Services.AddSingleton(resourceMetadataService); - - // Mark the service as having authorization enabled - builder.Services.AddSingleton(); - - // Add authentication with the MCP authentication handler - builder.Services.AddAuthentication() - .AddMcp(options => - { - // Default to the standard OAuth protected resource endpoint - options.ResourceMetadataUri = new Uri("/.well-known/oauth-protected-resource", UriKind.Relative); - - // Apply custom configuration if provided - configureOptions?.Invoke(options); - }); - - // Add authorization services - builder.Services.AddAuthorization(options => - { - options.AddPolicy("McpAuth", policy => - { - policy.RequireAuthenticatedUser(); - }); - }); - - // Register the middleware for automatically adding WWW-Authenticate headers - // Store in DI that we need to use the middleware - builder.Services.AddSingleton(); - - return builder; - } } diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index 05220af8..3ebc5f31 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -67,7 +67,7 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo .WithDisplayName("MCP Resource Metadata"); // Apply authorization to MCP endpoints - mcpGroup.RequireAuthorization("McpAuth"); + mcpGroup.RequireAuthorization(McpAuthenticationDefaults.AuthenticationScheme); } return mcpGroup; From ff8a2b79294e99724850a47ce098960f646c5e70 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 14:53:34 -0700 Subject: [PATCH 037/128] Fix server devx --- samples/ProtectedMCPServer/Program.cs | 7 +++---- .../Auth/McpAuthorizationExtensions.cs | 4 ++++ .../Auth/McpAuthorizationMarker.cs | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 41e02ae0..b5b8cbb7 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -15,7 +15,7 @@ }) .AddScheme("Bearer", options => { }) .AddMcp(options => { - // Configure MCP authentication options with the resource metadata URI + // Ensure ResourceMetadataUri matches the actual mapping in McpEndpointRouteBuilderExtensions.cs options.ResourceMetadataUri = new Uri("/.well-known/oauth-protected-resource", UriKind.Relative); // Configure the resource metadata using our enhanced options @@ -38,9 +38,6 @@ // Don't forget to register the ResourceMetadataService builder.Services.AddSingleton(); -// IMPORTANT: Register the McpAuthorizationMarker to enable authorization on MCP endpoints -builder.Services.AddSingleton(); - // Configure MCP Server builder.Services.AddMcpServer(options => { @@ -107,6 +104,8 @@ app.UseAuthorization(); // Map MCP endpoints with authorization +// Note: The SDK will automatically map /.well-known/oauth-protected-resource +// and make it accessible without authorization app.MapMcp(); Console.WriteLine("Starting MCP server with authorization at http://localhost:7071"); diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs index db9f19c6..a544747f 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -41,6 +41,10 @@ public static AuthenticationBuilder AddMcp( Action? configureOptions = null) { builder.Services.TryAddSingleton(); + + // Register the marker to indicate that MCP authorization is configured + // This will be used by MapMcp to apply authorization to endpoints + builder.Services.TryAddSingleton(); return builder.AddScheme( authenticationScheme, diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationMarker.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationMarker.cs index 12bf3961..e4fe4702 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationMarker.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationMarker.cs @@ -3,4 +3,4 @@ namespace ModelContextProtocol.AspNetCore.Auth; /// /// Marker class to indicate that MCP authorization has been configured. /// -internal class McpAuthorizationMarker { } +public class McpAuthorizationMarker { } From 03438d21c3bbfa4c3aebb7e204f49e6e103d531d Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 14:55:59 -0700 Subject: [PATCH 038/128] Defaults are applied here - no need to redeclare --- samples/ProtectedMCPServer/Program.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index b5b8cbb7..cf112521 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -15,11 +15,6 @@ }) .AddScheme("Bearer", options => { }) .AddMcp(options => { - // Ensure ResourceMetadataUri matches the actual mapping in McpEndpointRouteBuilderExtensions.cs - options.ResourceMetadataUri = new Uri("/.well-known/oauth-protected-resource", UriKind.Relative); - - // Configure the resource metadata using our enhanced options - options.ResourceMetadata.Resource = new Uri("http://localhost:7071"); options.ResourceMetadata.AuthorizationServers.Add(new Uri("https://login.microsoftonline.com/a2213e1c-e51e-4304-9a0d-effe57f31655/v2.0")); options.ResourceMetadata.BearerMethodsSupported.Add("header"); options.ResourceMetadata.ScopesSupported.AddRange(["weather.read", "weather.write"]); From a4f249586d0afcf94414cdd7450661ae8533a4ac Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 15:02:10 -0700 Subject: [PATCH 039/128] Proper authorization configuration --- samples/ProtectedMCPServer/Program.cs | 5 +---- .../McpEndpointRouteBuilderExtensions.cs | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index cf112521..0a440262 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -98,10 +98,7 @@ app.UseAuthentication(); app.UseAuthorization(); -// Map MCP endpoints with authorization -// Note: The SDK will automatically map /.well-known/oauth-protected-resource -// and make it accessible without authorization -app.MapMcp(); +app.MapMcp().RequireAuthorization(McpAuthenticationDefaults.AuthenticationScheme); Console.WriteLine("Starting MCP server with authorization at http://localhost:7071"); Console.WriteLine("PRM Document URL: http://localhost:7071/.well-known/oauth-protected-resource"); diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index 3ebc5f31..2641de32 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -65,9 +65,6 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["application/json"])) .AllowAnonymous() .WithDisplayName("MCP Resource Metadata"); - - // Apply authorization to MCP endpoints - mcpGroup.RequireAuthorization(McpAuthenticationDefaults.AuthenticationScheme); } return mcpGroup; From 2cdee6ed47daf5e7ed2319e9677e940660bdfd66 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 15:08:14 -0700 Subject: [PATCH 040/128] Simplify defaults --- .../Auth/McpAuthenticationHandler.cs | 2 +- .../Auth/McpAuthenticationOptions.cs | 4 +++- .../McpEndpointRouteBuilderExtensions.cs | 7 ++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index 9c944223..7f07bb83 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -43,7 +43,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // Generate the full resource metadata URL based on the current request var baseUrl = $"{Request.Scheme}://{Request.Host}"; - var metadataPath = Options.ResourceMetadataUri?.ToString() ?? "/.well-known/oauth-protected-resource"; + var metadataPath = Options.ResourceMetadataUri.ToString(); var metadataUrl = metadataPath.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? metadataPath : $"{baseUrl}{metadataPath}"; diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs index d872d754..836b91e0 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs @@ -8,13 +8,15 @@ namespace ModelContextProtocol.AspNetCore.Auth; /// public class McpAuthenticationOptions : AuthenticationSchemeOptions { + private static readonly Uri DefaultResourceMetadataUri = new("/.well-known/oauth-protected-resource", UriKind.Relative); + /// /// The URI to the resource metadata document. /// /// /// This URI will be included in the WWW-Authenticate header when a 401 response is returned. /// - public Uri? ResourceMetadataUri { get; set; } + public Uri ResourceMetadataUri { get; set; } = DefaultResourceMetadataUri; /// /// Gets or sets the protected resource metadata. diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index 2641de32..18800de3 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using ModelContextProtocol.AspNetCore; using ModelContextProtocol.AspNetCore.Auth; using ModelContextProtocol.Protocol.Messages; @@ -61,7 +62,11 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo // Use an AOT-compatible approach with a statically compiled RequestDelegate var handler = new ResourceMetadataEndpointHandler(resourceMetadataService); - sseGroup.MapGet("/.well-known/oauth-protected-resource", handler.HandleRequest) + // Get the options to use the default resource metadata URI + var options = endpoints.ServiceProvider.GetRequiredService>(); + var metadataPath = options.Value.ResourceMetadataUri.ToString(); + + sseGroup.MapGet(metadataPath, handler.HandleRequest) .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["application/json"])) .AllowAnonymous() .WithDisplayName("MCP Resource Metadata"); From e79dcb849fec8bf508a1290b732c5376a298cb91 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 15:10:24 -0700 Subject: [PATCH 041/128] Update McpEndpointRouteBuilderExtensions.cs --- .../McpEndpointRouteBuilderExtensions.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index 18800de3..0890a1a6 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -59,10 +59,8 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo // Authorization is configured, so automatically map the OAuth protected resource endpoint var resourceMetadataService = endpoints.ServiceProvider.GetRequiredService(); - // Use an AOT-compatible approach with a statically compiled RequestDelegate var handler = new ResourceMetadataEndpointHandler(resourceMetadataService); - // Get the options to use the default resource metadata URI var options = endpoints.ServiceProvider.GetRequiredService>(); var metadataPath = options.Value.ResourceMetadataUri.ToString(); From 7903c7591e188805f0818977de36f74716514302 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 15:11:09 -0700 Subject: [PATCH 042/128] Update Program.cs --- samples/ProtectedMCPServer/Program.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 0a440262..f66fd323 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -167,7 +167,4 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(AuthenticateResult.Success(ticket)); } - - // The MCP authentication handler will handle challenges - // so we don't need to implement HandleChallengeAsync here } From fbb18174500453305535651000565d1f53e5efcd Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 15:18:13 -0700 Subject: [PATCH 043/128] Simplify tool configuration --- samples/ProtectedMCPServer/Program.cs | 90 +++++++-------------------- 1 file changed, 22 insertions(+), 68 deletions(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index f66fd323..6e62ccee 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -1,20 +1,22 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; using ModelContextProtocol.AspNetCore.Auth; -using ModelContextProtocol.Protocol.Types; +using ProtectedMCPServer.Tools; +using System.Net.Http.Headers; using System.Security.Claims; using System.Text.Encodings.Web; var builder = WebApplication.CreateBuilder(args); // Configure authentication to use MCP for challenges -builder.Services.AddAuthentication(options => +builder.Services.AddAuthentication(options => { options.DefaultScheme = "Bearer"; options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme; // Use MCP for challenges }) .AddScheme("Bearer", options => { }) -.AddMcp(options => { +.AddMcp(options => +{ options.ResourceMetadata.AuthorizationServers.Add(new Uri("https://login.microsoftonline.com/a2213e1c-e51e-4304-9a0d-effe57f31655/v2.0")); options.ResourceMetadata.BearerMethodsSupported.Add("header"); options.ResourceMetadata.ScopesSupported.AddRange(["weather.read", "weather.write"]); @@ -34,65 +36,17 @@ builder.Services.AddSingleton(); // Configure MCP Server -builder.Services.AddMcpServer(options => -{ - options.ServerInstructions = "This is an MCP server with OAuth authorization enabled."; - - // Configure regular server capabilities like tools, prompts, resources - options.Capabilities = new() - { - Tools = new() - { - // Simple Echo tool - CallToolHandler = (request, cancellationToken) => - { - if (request.Params?.Name == "echo") - { - if (request.Params.Arguments?.TryGetValue("message", out var message) is not true) - { - throw new Exception("It happens."); - } - - return new ValueTask(new CallToolResponse() - { - Content = [new Content() { Text = $"Echo: {message}", Type = "text" }] - }); - } - - // Protected tool that requires authorization - if (request.Params?.Name == "protected-data") - { - // This tool will only be accessible to authenticated clients - return new ValueTask(new CallToolResponse() - { - Content = [new Content() { Text = "This is protected data that only authorized clients can access" }] - }); - } - - throw new Exception("It happens."); - }, - - ListToolsHandler = async (_, _) => new() - { - Tools = - [ - new() - { - Name = "echo", - Description = "Echoes back the message you send" - }, - new() - { - Name = "protected-data", - Description = "Returns protected data that requires authorization" - } - ] - } - } - }; -}) +builder.Services.AddMcpServer() +.WithTools() .WithHttpTransport(); +builder.Services.AddSingleton(_ => +{ + var client = new HttpClient() { BaseAddress = new Uri("https://api.weather.gov") }; + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0")); + return client; +}); + var app = builder.Build(); app.UseAuthentication(); @@ -122,7 +76,7 @@ class SimpleAuthHandler : AuthenticationHandler public SimpleAuthHandler( IOptionsMonitor options, ILoggerFactory logger, - UrlEncoder encoder) + UrlEncoder encoder) : base(options, logger, encoder) { } @@ -134,25 +88,25 @@ protected override Task HandleAuthenticateAsync() { return Task.FromResult(AuthenticateResult.Fail("Authorization header missing")); } - + // Parse the token var headerValue = authHeader.ToString(); if (!headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { return Task.FromResult(AuthenticateResult.Fail("Bearer token missing")); } - + var token = headerValue["Bearer ".Length..].Trim(); - + // Accept any non-empty token for testing purposes if (string.IsNullOrEmpty(token)) { return Task.FromResult(AuthenticateResult.Fail("Token cannot be empty")); } - + // Log the received token for debugging Console.WriteLine($"Received and accepted token: {token}"); - + // Create a claims identity with required claims var claims = new[] { @@ -160,11 +114,11 @@ protected override Task HandleAuthenticateAsync() new Claim(ClaimTypes.NameIdentifier, "user123"), new Claim("scope", "weather.read") }; - + var identity = new ClaimsIdentity(claims, "Bearer"); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, "Bearer"); - + return Task.FromResult(AuthenticateResult.Success(ticket)); } } From 6aef68bfa9ace4e04e27c0efbf39da9347df0b21 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 16:52:52 -0700 Subject: [PATCH 044/128] Update config --- Directory.Packages.props | 14 +++++--------- samples/ProtectedMCPClient/Program.cs | 2 +- samples/ProtectedMCPServer/Program.cs | 3 --- .../ProtectedMCPServer/ProtectedMCPServer.csproj | 2 ++ 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 00234fa3..bb2a1e32 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,9 +4,9 @@ 10.0.0-preview.3.25171.5 9.4.3-preview.1.25230.7 - + @@ -14,33 +14,29 @@ - - + + - - - - @@ -61,8 +57,8 @@ - - + + diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 9746e631..3377c002 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -18,7 +18,7 @@ static async Task Main(string[] args) var authConfig = new AuthorizationConfig { ClientId = "04f79824-ab56-4511-a7cb-d7deaea92dc0", - Scopes = ["User.Read"] + Scopes = ["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"] }.UseHttpListener(hostname: "localhost", listenPort: 1170); // Create an HTTP client with OAuth handling diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 6e62ccee..ba91cd9b 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -32,9 +32,6 @@ }); }); -// Don't forget to register the ResourceMetadataService -builder.Services.AddSingleton(); - // Configure MCP Server builder.Services.AddMcpServer() .WithTools() diff --git a/samples/ProtectedMCPServer/ProtectedMCPServer.csproj b/samples/ProtectedMCPServer/ProtectedMCPServer.csproj index 76cb3a24..df2a4d81 100644 --- a/samples/ProtectedMCPServer/ProtectedMCPServer.csproj +++ b/samples/ProtectedMCPServer/ProtectedMCPServer.csproj @@ -8,6 +8,8 @@ + + \ No newline at end of file From 1fd164c1508a937a78930fe111ccdb80c08c9f75 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 17:48:48 -0700 Subject: [PATCH 045/128] Proper setup --- .../Auth/McpAuthenticationHandler.cs | 15 ---------- .../Auth/McpAuthorizationExtensions.cs | 29 ++++++++++++++++--- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index 7f07bb83..16b01352 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -51,21 +51,6 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // Initialize properties if null properties ??= new AuthenticationProperties(); - // Set up the resource URI if not already configured, using the current request as a fallback - if (Options.ResourceMetadata.Resource == null) - { - Options.ResourceMetadata.Resource = new Uri($"{Request.Scheme}://{Request.Host}"); - } - - // Configure the resource metadata service with our metadata - _resourceMetadataService.ConfigureMetadata(metadata => { - metadata.Resource = Options.ResourceMetadata.Resource; - metadata.AuthorizationServers = Options.ResourceMetadata.AuthorizationServers; - metadata.BearerMethodsSupported = Options.ResourceMetadata.BearerMethodsSupported; - metadata.ScopesSupported = Options.ResourceMetadata.ScopesSupported; - metadata.ResourceDocumentation = Options.ResourceMetadata.ResourceDocumentation; - }); - // Set the WWW-Authenticate header with the resource_metadata string headerValue = $"Bearer realm=\"{Scheme.Name}\""; headerValue += $", resource_metadata=\"{metadataUrl}\""; diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs index a544747f..e2d20f16 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -40,15 +40,36 @@ public static AuthenticationBuilder AddMcp( string displayName, Action? configureOptions = null) { - builder.Services.TryAddSingleton(); + // Create options instance to pass to ResourceMetadataService + var options = new McpAuthenticationOptions(); + configureOptions?.Invoke(options); + + // Register ResourceMetadataService with options + builder.Services.AddSingleton(sp => { + var service = new ResourceMetadataService(); + + // Configure the service with the resource metadata from options + service.ConfigureMetadata(metadata => { + metadata.Resource = options.ResourceMetadata.Resource; + metadata.AuthorizationServers = options.ResourceMetadata.AuthorizationServers; + metadata.BearerMethodsSupported = options.ResourceMetadata.BearerMethodsSupported; + metadata.ScopesSupported = options.ResourceMetadata.ScopesSupported; + metadata.ResourceDocumentation = options.ResourceMetadata.ResourceDocumentation; + }); + + return service; + }); - // Register the marker to indicate that MCP authorization is configured - // This will be used by MapMcp to apply authorization to endpoints builder.Services.TryAddSingleton(); return builder.AddScheme( authenticationScheme, displayName, - configureOptions ?? (options => { })); + opt => { + if (configureOptions != null) + { + configureOptions(opt); + } + }); } } From 0badaf8a0d59cba442ae709f8022e41a39759c81 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 19:22:39 -0700 Subject: [PATCH 046/128] Update McpAuthenticationHandler.cs --- .../Auth/McpAuthenticationHandler.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index 16b01352..54cf11f0 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -1,9 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using System; using System.Text.Encodings.Web; -using System.Threading.Tasks; namespace ModelContextProtocol.AspNetCore.Auth; @@ -12,19 +10,15 @@ namespace ModelContextProtocol.AspNetCore.Auth; /// public class McpAuthenticationHandler : AuthenticationHandler { - private readonly ResourceMetadataService _resourceMetadataService; - /// /// Initializes a new instance of the class. /// public McpAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, - UrlEncoder encoder, - ResourceMetadataService resourceMetadataService) + UrlEncoder encoder) : base(options, logger, encoder) { - _resourceMetadataService = resourceMetadataService; } /// From 9209b6125ff3477e19a713ba157ac242a3d5e613 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 23:30:53 -0700 Subject: [PATCH 047/128] Update samples --- samples/ProtectedMCPClient/Program.cs | 2 +- samples/ProtectedMCPServer/Program.cs | 130 +++++++++--------- .../Auth/OAuthAuthenticationService.cs | 13 +- 3 files changed, 74 insertions(+), 71 deletions(-) diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 3377c002..fd3646a5 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -17,7 +17,7 @@ static async Task Main(string[] args) // Create the authorization config with HTTP listener var authConfig = new AuthorizationConfig { - ClientId = "04f79824-ab56-4511-a7cb-d7deaea92dc0", + ClientId = "6ad97b5f-7a7b-413f-8603-7a3517d4adb8", Scopes = ["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"] }.UseHttpListener(hostname: "localhost", listenPort: 1170); diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index ba91cd9b..52866501 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -1,25 +1,76 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; using ModelContextProtocol.AspNetCore.Auth; using ProtectedMCPServer.Tools; using System.Net.Http.Headers; using System.Security.Claims; -using System.Text.Encodings.Web; var builder = WebApplication.CreateBuilder(args); -// Configure authentication to use MCP for challenges +// Define Entra ID (Azure AD) configuration +var tenantId = "a2213e1c-e51e-4304-9a0d-effe57f31655"; // This is the tenant ID from your existing configuration +var instance = "https://login.microsoftonline.com/"; + +// Configure authentication to use MCP for challenges and Entra ID JWT Bearer for token validation builder.Services.AddAuthentication(options => { - options.DefaultScheme = "Bearer"; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme; // Use MCP for challenges }) -.AddScheme("Bearer", options => { }) +.AddJwtBearer(options => +{ + // Configure for Entra ID (Azure AD) token validation + options.Authority = $"{instance}{tenantId}/v2.0"; + options.TokenValidationParameters = new TokenValidationParameters + { + // Configure validation parameters for Entra ID tokens + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + + // Default audience - you should replace this with your actual app/API registration ID + ValidAudience = "167b4284-3f92-4436-92ed-38b38f83ae08", + + // This validates that tokens come from your Entra ID tenant + ValidIssuer = $"{instance}{tenantId}/v2.0", + + // These claims are used by the app for identity representation + NameClaimType = "name", + RoleClaimType = "roles" + }; + + // Enable metadata-based issuer key retrieval + options.MetadataAddress = $"{instance}{tenantId}/v2.0/.well-known/openid-configuration"; + + // Add development mode debug logging for token validation + 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.AuthorizationServers.Add(new Uri("https://login.microsoftonline.com/a2213e1c-e51e-4304-9a0d-effe57f31655/v2.0")); + // Configure the MCP authentication with the same Entra ID server + options.ResourceMetadata.AuthorizationServers.Add(new Uri($"{instance}{tenantId}/v2.0")); options.ResourceMetadata.BearerMethodsSupported.Add("header"); - options.ResourceMetadata.ScopesSupported.AddRange(["weather.read", "weather.write"]); + options.ResourceMetadata.ScopesSupported.AddRange(["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"]); options.ResourceMetadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); }); @@ -55,67 +106,14 @@ Console.WriteLine("PRM Document URL: http://localhost:7071/.well-known/oauth-protected-resource"); Console.WriteLine(); -Console.WriteLine("Testing mode: Server will accept ANY non-empty token for authentication"); +Console.WriteLine("Entra ID (Azure AD) JWT token validation is configured"); Console.WriteLine(); Console.WriteLine("To test the server:"); -Console.WriteLine("1. Use an MCP client that supports authorization"); -Console.WriteLine("2. The server will accept any non-empty token sent by the client"); -Console.WriteLine("3. Tokens will be logged to the console for debugging"); +Console.WriteLine("1. Use an MCP client that supports OAuth flow with Microsoft Entra ID"); +Console.WriteLine("2. The client should obtain a token for audience: api://weather-api"); +Console.WriteLine("3. The token should be issued by Microsoft Entra ID tenant: " + tenantId); +Console.WriteLine("4. Include this token in the Authorization header of requests"); Console.WriteLine(); Console.WriteLine("Press Ctrl+C to stop the server"); app.Run("http://localhost:7071/"); - -// Simple auth handler that accepts any non-empty token for testing -// In a real app, you'd use a JWT handler or other proper authentication -class SimpleAuthHandler : AuthenticationHandler -{ - public SimpleAuthHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder) - : base(options, logger, encoder) - { - } - - protected override Task HandleAuthenticateAsync() - { - // Get the Authorization header - if (!Request.Headers.TryGetValue("Authorization", out var authHeader)) - { - return Task.FromResult(AuthenticateResult.Fail("Authorization header missing")); - } - - // Parse the token - var headerValue = authHeader.ToString(); - if (!headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(AuthenticateResult.Fail("Bearer token missing")); - } - - var token = headerValue["Bearer ".Length..].Trim(); - - // Accept any non-empty token for testing purposes - if (string.IsNullOrEmpty(token)) - { - return Task.FromResult(AuthenticateResult.Fail("Token cannot be empty")); - } - - // Log the received token for debugging - Console.WriteLine($"Received and accepted token: {token}"); - - // Create a claims identity with required claims - var claims = new[] - { - new Claim(ClaimTypes.Name, "demo_user"), - new Claim(ClaimTypes.NameIdentifier, "user123"), - new Claim("scope", "weather.read") - }; - - var identity = new ClaimsIdentity(claims, "Bearer"); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, "Bearer"); - - return Task.FromResult(AuthenticateResult.Success(ticket)); - } -} diff --git a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs index b94d5444..d6f1e7fa 100644 --- a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs +++ b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs @@ -165,10 +165,15 @@ private void VerifyResourceUri(Uri resourceUri, Uri metadataResourceUri) private async Task DiscoverAuthorizationServerMetadataAsync(Uri authServerUri) { - // Try common well-known endpoints - var openIdConfigUri = new Uri(authServerUri, ".well-known/openid-configuration"); - var oauthConfigUri = new Uri(authServerUri, ".well-known/oauth-authorization-server"); - + // Ensure the authServerUri ends with a trailing slash + var baseUri = authServerUri.AbsoluteUri.EndsWith("/") + ? authServerUri + : new Uri(authServerUri.AbsoluteUri + "/"); + + // Now combine with the well-known endpoints + var openIdConfigUri = new Uri(baseUri, ".well-known/openid-configuration"); + var oauthConfigUri = new Uri(baseUri, ".well-known/oauth-authorization-server"); + // Try OpenID Connect configuration endpoint first try { From a90d01eadd46c4b0fda47fdb292b1a6b73c885f6 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 23:38:14 -0700 Subject: [PATCH 048/128] Consolidate some of the PKCE logic. --- .../Auth/AuthorizationHandlers.cs | 79 ------------ .../Auth/OAuthAuthenticationService.cs | 120 ++++++++++++------ .../Auth/Types/AuthorizationCodeOptions.cs | 2 +- 3 files changed, 83 insertions(+), 118 deletions(-) delete mode 100644 src/ModelContextProtocol/Auth/AuthorizationHandlers.cs diff --git a/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs b/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs deleted file mode 100644 index 9fe7535d..00000000 --- a/src/ModelContextProtocol/Auth/AuthorizationHandlers.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace ModelContextProtocol.Auth; - -/// -/// Provides utilities for PKCE (Proof Key for Code Exchange) in OAuth authorization flows. -/// -public static class PkceUtility -{ - /// - /// Represents the PKCE code challenge and verifier for an authorization flow. - /// - public class PkceValues - { - /// - /// The code verifier used to generate the code challenge. - /// - public string CodeVerifier { get; } - - /// - /// The code challenge sent to the authorization server. - /// - public string CodeChallenge { get; } - - /// - /// Initializes a new instance of the class. - /// - public PkceValues(string codeVerifier, string codeChallenge) - { - CodeVerifier = codeVerifier; - CodeChallenge = codeChallenge; - } - } - - /// - /// Generates new PKCE values. - /// - /// A instance containing the code verifier and challenge. - public static PkceValues GeneratePkceValues() - { - var codeVerifier = GenerateCodeVerifier(); - var codeChallenge = GenerateCodeChallenge(codeVerifier); - return new PkceValues(codeVerifier, codeChallenge); - } - - private static string GenerateCodeVerifier() - { - // Generate a cryptographically random code verifier - var bytes = new byte[64]; - using var rng = RandomNumberGenerator.Create(); - rng.GetBytes(bytes); - - // Base64url encode the random bytes - var base64 = Convert.ToBase64String(bytes); - var base64Url = base64 - .Replace('+', '-') - .Replace('/', '_') - .Replace("=", ""); - - return base64Url; - } - - private static string GenerateCodeChallenge(string codeVerifier) - { - // Create code challenge using S256 method - using var sha256 = SHA256.Create(); - var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); - - // Base64url encode the hash - var base64 = Convert.ToBase64String(challengeBytes); - var base64Url = base64 - .Replace('+', '-') - .Replace('/', '_') - .Replace("=", ""); - - return base64Url; - } -} diff --git a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs index d6f1e7fa..ecbe3205 100644 --- a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs +++ b/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs @@ -16,6 +16,31 @@ public class OAuthAuthenticationService private static readonly HttpClient _httpClient = new(); private readonly Func>? _authorizationHandler; + /// + /// Represents the PKCE code challenge and verifier for an authorization flow. + /// + public class PkceValues + { + /// + /// The code verifier used to generate the code challenge. + /// + public string CodeVerifier { get; } + + /// + /// The code challenge sent to the authorization server. + /// + public string CodeChallenge { get; } + + /// + /// Initializes a new instance of the class. + /// + public PkceValues(string codeVerifier, string codeChallenge) + { + CodeVerifier = codeVerifier; + CodeChallenge = codeChallenge; + } + } + /// /// Initializes a new instance of the class. /// @@ -32,6 +57,59 @@ public OAuthAuthenticationService(Func> authorizationHandler) _authorizationHandler = authorizationHandler ?? throw new ArgumentNullException(nameof(authorizationHandler)); } + /// + /// Generates new PKCE values. + /// + /// A instance containing the code verifier and challenge. + public static PkceValues GeneratePkceValues() + { + var codeVerifier = GenerateCodeVerifier(); + var codeChallenge = GenerateCodeChallenge(codeVerifier); + return new PkceValues(codeVerifier, codeChallenge); + } + + /// + /// Generates a cryptographically random code verifier for PKCE. + /// + /// A base64url encoded string to be used as the code verifier. + public static string GenerateCodeVerifier() + { + // Generate a cryptographically random code verifier + var bytes = new byte[64]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + + // Base64url encode the random bytes + var base64 = Convert.ToBase64String(bytes); + var base64Url = base64 + .Replace('+', '-') + .Replace('/', '_') + .Replace("=", ""); + + return base64Url; + } + + /// + /// Generates a code challenge from a code verifier using the S256 method. + /// + /// The code verifier to generate the challenge from. + /// A base64url encoded SHA256 hash of the code verifier. + public static string GenerateCodeChallenge(string codeVerifier) + { + // Create code challenge using S256 method + using var sha256 = SHA256.Create(); + var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); + + // Base64url encode the hash + var base64 = Convert.ToBase64String(challengeBytes); + var base64Url = base64 + .Replace('+', '-') + .Replace('/', '_') + .Replace("=", ""); + + return base64Url; + } + /// /// Handles the OAuth authentication flow when a 401 Unauthorized response is received. /// @@ -251,16 +329,15 @@ private async Task PerformAuthorizationCodeFlowAsync( IEnumerable scopes, Func>? authorizationHandler) { - // Generate PKCE code verifier and challenge - var codeVerifier = GenerateCodeVerifier(); - var codeChallenge = GenerateCodeChallenge(codeVerifier); + // Generate PKCE values using our public method + var pkceValues = GeneratePkceValues(); // Build authorization URL var authorizationUrl = BuildAuthorizationUrl( authServerMetadata.AuthorizationEndpoint, clientId, redirectUri, - codeChallenge, + pkceValues.CodeChallenge, scopes); // Check if an authorization handler is available @@ -278,7 +355,7 @@ private async Task PerformAuthorizationCodeFlowAsync( clientSecret, redirectUri, authorizationCode, - codeVerifier); + pkceValues.CodeVerifier); } catch (Exception ex) { @@ -293,39 +370,6 @@ private async Task PerformAuthorizationCodeFlowAsync( $"You need to handle this redirect and extract the authorization code to complete the flow."); } - private string GenerateCodeVerifier() - { - // Generate a cryptographically random code verifier - var bytes = new byte[64]; - using var rng = RandomNumberGenerator.Create(); - rng.GetBytes(bytes); - - // Base64url encode the random bytes - var base64 = Convert.ToBase64String(bytes); - var base64Url = base64 - .Replace('+', '-') - .Replace('/', '_') - .Replace("=", ""); - - return base64Url; - } - - private string GenerateCodeChallenge(string codeVerifier) - { - // Create code challenge using S256 method - using var sha256 = SHA256.Create(); - var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); - - // Base64url encode the hash - var base64 = Convert.ToBase64String(challengeBytes); - var base64Url = base64 - .Replace('+', '-') - .Replace('/', '_') - .Replace("=", ""); - - return base64Url; - } - private string BuildAuthorizationUrl( Uri authorizationEndpoint, string clientId, diff --git a/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs b/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs index 67ee1154..1aa0e5a8 100644 --- a/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs +++ b/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs @@ -38,7 +38,7 @@ public class AuthorizationCodeOptions /// /// PKCE values for the authorization flow. /// - public PkceUtility.PkceValues PkceValues { get; set; } = null!; + public OAuthAuthenticationService.PkceValues PkceValues { get; set; } = null!; /// /// A state value to protect against CSRF attacks. From f3a3715c1c8a83dc29e96ae24b05c58e8ebe86e4 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 23:41:52 -0700 Subject: [PATCH 049/128] Consolidate some OAuth logic --- .../Auth/AuthorizationConfigExtensions.cs | 2 +- .../Auth/McpClientExtensions.cs | 6 ++-- ...uthorizationHelpers.cs => OAuthHelpers.cs} | 11 ++---- ...thenticationService.cs => OAuthService.cs} | 35 +++---------------- .../Auth/Types/AuthorizationCodeOptions.cs | 2 +- .../Auth/Types/PkceValues.cs | 6 ++++ 6 files changed, 19 insertions(+), 43 deletions(-) rename src/ModelContextProtocol/Auth/{OAuthAuthorizationHelpers.cs => OAuthHelpers.cs} (98%) rename src/ModelContextProtocol/Auth/{OAuthAuthenticationService.cs => OAuthService.cs} (93%) create mode 100644 src/ModelContextProtocol/Auth/Types/PkceValues.cs diff --git a/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs b/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs index 8d3bff78..77b2c612 100644 --- a/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs +++ b/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs @@ -49,7 +49,7 @@ public static AuthorizationConfig UseHttpListener( openBrowser ??= DefaultOpenBrowser; // Configure the handler - config.AuthorizationHandler = OAuthAuthorizationHelpers.CreateHttpListenerCallback( + config.AuthorizationHandler = OAuthHelpers.CreateHttpListenerCallback( openBrowser, hostname, listenPort, diff --git a/src/ModelContextProtocol/Auth/McpClientExtensions.cs b/src/ModelContextProtocol/Auth/McpClientExtensions.cs index 68a2b8ef..2185e334 100644 --- a/src/ModelContextProtocol/Auth/McpClientExtensions.cs +++ b/src/ModelContextProtocol/Auth/McpClientExtensions.cs @@ -98,9 +98,9 @@ public static async Task HandleUnauthorizedResponseAsync( } // Create OAuthAuthenticationService - use appropriate constructor based on whether we have a handler - OAuthAuthenticationService authService = config.AuthorizationHandler != null - ? new OAuthAuthenticationService(config.AuthorizationHandler) - : new OAuthAuthenticationService(); + OAuthService authService = config.AuthorizationHandler != null + ? new OAuthService(config.AuthorizationHandler) + : new OAuthService(); // Get resource URI var resourceUri = response.RequestMessage?.RequestUri ?? throw new InvalidOperationException("Request URI is not available."); diff --git a/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs b/src/ModelContextProtocol/Auth/OAuthHelpers.cs similarity index 98% rename from src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs rename to src/ModelContextProtocol/Auth/OAuthHelpers.cs index ba7c65b5..76b61849 100644 --- a/src/ModelContextProtocol/Auth/OAuthAuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Auth/OAuthHelpers.cs @@ -1,21 +1,16 @@ -using System; -using System.Collections.Generic; +using ModelContextProtocol.Auth.Types; +using ModelContextProtocol.Utils.Json; using System.Net; -using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using ModelContextProtocol.Auth.Types; -using ModelContextProtocol.Utils.Json; namespace ModelContextProtocol.Auth; /// /// Provides helper methods for handling OAuth authorization. /// -public static class OAuthAuthorizationHelpers +public static class OAuthHelpers { private static readonly HttpClient _httpClient = new(); diff --git a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs b/src/ModelContextProtocol/Auth/OAuthService.cs similarity index 93% rename from src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs rename to src/ModelContextProtocol/Auth/OAuthService.cs index ecbe3205..d90b0314 100644 --- a/src/ModelContextProtocol/Auth/OAuthAuthenticationService.cs +++ b/src/ModelContextProtocol/Auth/OAuthService.cs @@ -11,48 +11,23 @@ namespace ModelContextProtocol.Auth; /// /// Provides functionality for OAuth authentication in MCP clients. /// -public class OAuthAuthenticationService +public partial class OAuthService { private static readonly HttpClient _httpClient = new(); private readonly Func>? _authorizationHandler; /// - /// Represents the PKCE code challenge and verifier for an authorization flow. + /// Initializes a new instance of the class. /// - public class PkceValues - { - /// - /// The code verifier used to generate the code challenge. - /// - public string CodeVerifier { get; } - - /// - /// The code challenge sent to the authorization server. - /// - public string CodeChallenge { get; } - - /// - /// Initializes a new instance of the class. - /// - public PkceValues(string codeVerifier, string codeChallenge) - { - CodeVerifier = codeVerifier; - CodeChallenge = codeChallenge; - } - } - - /// - /// Initializes a new instance of the class. - /// - public OAuthAuthenticationService() + public OAuthService() { } /// - /// Initializes a new instance of the class with an authorization handler. + /// Initializes a new instance of the class with an authorization handler. /// /// A handler to invoke when authorization is required. - public OAuthAuthenticationService(Func> authorizationHandler) + public OAuthService(Func> authorizationHandler) { _authorizationHandler = authorizationHandler ?? throw new ArgumentNullException(nameof(authorizationHandler)); } diff --git a/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs b/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs index 1aa0e5a8..09eb49f6 100644 --- a/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs +++ b/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs @@ -38,7 +38,7 @@ public class AuthorizationCodeOptions /// /// PKCE values for the authorization flow. /// - public OAuthAuthenticationService.PkceValues PkceValues { get; set; } = null!; + public PkceValues PkceValues { get; set; } = null!; /// /// A state value to protect against CSRF attacks. diff --git a/src/ModelContextProtocol/Auth/Types/PkceValues.cs b/src/ModelContextProtocol/Auth/Types/PkceValues.cs new file mode 100644 index 00000000..f413cb39 --- /dev/null +++ b/src/ModelContextProtocol/Auth/Types/PkceValues.cs @@ -0,0 +1,6 @@ +namespace ModelContextProtocol.Auth; + + +/// The code verifier used to generate the code challenge. +/// The code challenge sent to the authorization server. +public record PkceValues(string CodeVerifier, string CodeChallenge); From e04447584b275da936ed44bdb5e2e9f26e23aa62 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 2 May 2025 23:43:23 -0700 Subject: [PATCH 050/128] Update McpAuthenticationHandler.cs --- .../Auth/McpAuthenticationHandler.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index 54cf11f0..bcfa795e 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Text.Encodings.Web; @@ -33,7 +34,7 @@ protected override Task HandleAuthenticateAsync() protected override Task HandleChallengeAsync(AuthenticationProperties properties) { // Set the response status code - Response.StatusCode = 401; // Unauthorized + Response.StatusCode = StatusCodes.Status401Unauthorized; // Generate the full resource metadata URL based on the current request var baseUrl = $"{Request.Scheme}://{Request.Host}"; From 8790a3549c0d59e3377600c52efd77c7a892ee01 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 00:06:20 -0700 Subject: [PATCH 051/128] Clean up the helper --- src/ModelContextProtocol/Auth/OAuthHelpers.cs | 170 +++++++----------- 1 file changed, 68 insertions(+), 102 deletions(-) diff --git a/src/ModelContextProtocol/Auth/OAuthHelpers.cs b/src/ModelContextProtocol/Auth/OAuthHelpers.cs index 76b61849..62c6f2cf 100644 --- a/src/ModelContextProtocol/Auth/OAuthHelpers.cs +++ b/src/ModelContextProtocol/Auth/OAuthHelpers.cs @@ -30,31 +30,14 @@ public static Func> CreateHttpListenerCallback( int listenPort = 8888, string redirectPath = "/callback") { - return async (Uri authorizationUri) => + return async authorizationUri => { - string redirectUri = $"http://{hostname}:{listenPort}{redirectPath}"; - - // Add the redirect_uri parameter to the authorization URI if it's not already present - string authUrl = authorizationUri.ToString(); - if (!authUrl.Contains("redirect_uri=")) - { - var separator = authUrl.Contains("?") ? "&" : "?"; - authUrl = $"{authUrl}{separator}redirect_uri={WebUtility.UrlEncode(redirectUri)}"; - } - - var authCodeTcs = new TaskCompletionSource(); - // Ensure the path has a trailing slash for the HttpListener prefix - string listenerPrefix = $"http://{hostname}:{listenPort}{redirectPath}"; - if (!listenerPrefix.EndsWith("/")) - { - listenerPrefix += "/"; - } + var listenerPrefix = $"http://{hostname}:{listenPort}{redirectPath.TrimEnd('/')}/"; using var listener = new HttpListener(); listener.Prefixes.Add(listenerPrefix); - - // Start the listener BEFORE opening the browser + try { listener.Start(); @@ -64,101 +47,84 @@ public static Func> CreateHttpListenerCallback( throw new InvalidOperationException($"Failed to start HTTP listener on {listenerPrefix}: {ex.Message}"); } - // Create a cancellation token source with a timeout using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - _ = Task.Run(async () => - { - try - { - // GetContextAsync doesn't accept a cancellation token, so we need to handle cancellation manually - var contextTask = listener.GetContextAsync(); - var completedTask = await Task.WhenAny(contextTask, Task.Delay(Timeout.Infinite, cts.Token)); - - if (completedTask == contextTask) - { - var context = await contextTask; - var request = context.Request; - var response = context.Response; - - string? code = request.QueryString["code"]; - string? error = request.QueryString["error"]; - string html; - string? resultCode = null; + await openBrowser(authorizationUri.ToString()); - if (!string.IsNullOrEmpty(error)) - { - html = $"

Authorization Failed

Error: {WebUtility.HtmlEncode(error)}

"; - } - else if (string.IsNullOrEmpty(code)) - { - html = "

Authorization Failed

No authorization code received.

"; - } - else - { - html = "

Authorization Successful

You may now close this window.

"; - resultCode = code; - } - - try - { - // Send response to browser - byte[] buffer = Encoding.UTF8.GetBytes(html); - response.ContentType = "text/html"; - response.ContentLength64 = buffer.Length; - response.OutputStream.Write(buffer, 0, buffer.Length); - - // IMPORTANT: Explicitly close the response to ensure it's fully sent - response.Close(); - - // Now that we've finished processing the browser response, - // we can safely signal completion or failure with the auth code - if (resultCode != null) - { - authCodeTcs.TrySetResult(resultCode); - } - else if (!string.IsNullOrEmpty(error)) - { - authCodeTcs.TrySetException(new InvalidOperationException($"Authorization failed: {error}")); - } - else - { - authCodeTcs.TrySetException(new InvalidOperationException("No authorization code received")); - } - } - catch (Exception ex) - { - authCodeTcs.TrySetException(new InvalidOperationException($"Error processing browser response: {ex.Message}")); - } - } - } - catch (Exception ex) + try + { + var contextTask = listener.GetContextAsync(); + var completedTask = await Task.WhenAny(contextTask, Task.Delay(Timeout.Infinite, cts.Token)); + + if (completedTask != contextTask) { - authCodeTcs.TrySetException(ex); + throw new InvalidOperationException("Authorization timed out after 5 minutes."); } - }); - - // Now open the browser AFTER the listener is started - await openBrowser(authUrl); - try - { - // Use a timeout to avoid hanging indefinitely - string authCode = await authCodeTcs.Task.WaitAsync(cts.Token); - return authCode; - } - catch (OperationCanceledException) - { - throw new InvalidOperationException("Authorization timed out after 5 minutes."); + var context = await contextTask; + return ProcessCallback(context); } finally { - // Ensure the listener is stopped when we're done listener.Stop(); } }; } - + + /// + /// Processes the HTTP callback and extracts the authorization code. + /// + private static string ProcessCallback(HttpListenerContext context) + { + var request = context.Request; + var response = context.Response; + + string? code = request.QueryString["code"]; + string? error = request.QueryString["error"]; + string html; + + if (!string.IsNullOrEmpty(error)) + { + html = $"

Authorization Failed

Error: {WebUtility.HtmlEncode(error)}

"; + SendResponse(response, html); + throw new InvalidOperationException($"Authorization failed: {error}"); + } + + if (string.IsNullOrEmpty(code)) + { + html = "

Authorization Failed

No authorization code received.

"; + SendResponse(response, html); + throw new InvalidOperationException("No authorization code received"); + } + + html = "

Authorization Successful

You may now close this window.

"; + SendResponse(response, html); + return code; + } + + /// + /// Sends an HTML response to the browser. + /// + private static void SendResponse(HttpListenerResponse response, string html) + { + try + { + byte[] buffer = Encoding.UTF8.GetBytes(html); + response.ContentType = "text/html"; + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer, 0, buffer.Length); + + // IMPORTANT: Explicitly close the response to ensure it's fully sent + response.Close(); + } + catch + { + // Silently handle errors - we're already in an error handling path + // and can't throw further exceptions or log to the console in a library + // TODO: Need a better implementation here. + } + } + /// /// Exchanges an authorization code for an OAuth token. /// From 4cff0843d14be94f8c81d560bc67cd55a0a9ebf0 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 00:22:29 -0700 Subject: [PATCH 052/128] Minor changes to PRM doc logic --- .../Auth/McpAuthenticationHandler.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index bcfa795e..ad334ae3 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -38,22 +38,28 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // Generate the full resource metadata URL based on the current request var baseUrl = $"{Request.Scheme}://{Request.Host}"; - var metadataPath = Options.ResourceMetadataUri.ToString(); - var metadataUrl = metadataPath.StartsWith("http", StringComparison.OrdinalIgnoreCase) - ? metadataPath - : $"{baseUrl}{metadataPath}"; + + // Properly parse and validate the ResourceMetadataUri + if (!Uri.TryCreate(Options.ResourceMetadataUri.ToString(), UriKind.Absolute, out var prmDocumentUri)) + throw new InvalidOperationException("Invalid ResourceMetadataUri in options."); + + // Verify that the URI scheme starts with "http" + if (!prmDocumentUri.Scheme.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("ResourceMetadataUri must use HTTP or HTTPS scheme."); + + var rawPrmDocumentUri = prmDocumentUri.ToString(); // Initialize properties if null properties ??= new AuthenticationProperties(); // Set the WWW-Authenticate header with the resource_metadata string headerValue = $"Bearer realm=\"{Scheme.Name}\""; - headerValue += $", resource_metadata=\"{metadataUrl}\""; + headerValue += $", resource_metadata=\"{rawPrmDocumentUri}\""; Response.Headers["WWW-Authenticate"] = headerValue; // Store the resource_metadata in properties in case other handlers need it - properties.Items["resource_metadata"] = metadataUrl; + properties.Items["resource_metadata"] = rawPrmDocumentUri; return base.HandleChallengeAsync(properties); } From 6b768d8155dedf3f7ac4bfa1f845b9ad007e9ac3 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 13:05:58 -0700 Subject: [PATCH 053/128] Cleanup --- .../Auth/McpAuthenticationHandler.cs | 92 ++++++++++++++++--- .../Auth/McpAuthorizationExtensions.cs | 43 +++------ .../Auth/McpAuthorizationMarker.cs | 6 -- .../Auth/ResourceMetadataEndpointHandler.cs | 22 ----- .../Auth/ResourceMetadataService.cs | 52 ----------- .../McpEndpointRouteBuilderExtensions.cs | 18 ---- 6 files changed, 95 insertions(+), 138 deletions(-) delete mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationMarker.cs delete mode 100644 src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs delete mode 100644 src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataService.cs diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index ad334ae3..6cd70ec4 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -2,15 +2,20 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using ModelContextProtocol.Auth.Types; +using ModelContextProtocol.Utils.Json; using System.Text.Encodings.Web; namespace ModelContextProtocol.AspNetCore.Auth; /// -/// Authentication handler for MCP protocol that adds resource metadata to challenge responses. +/// Authentication handler for MCP protocol that adds resource metadata to challenge responses +/// and handles resource metadata endpoint requests. /// -public class McpAuthenticationHandler : AuthenticationHandler +public class McpAuthenticationHandler : AuthenticationHandler, IAuthenticationRequestHandler { + private readonly IOptionsMonitor _optionsMonitor; + /// /// Initializes a new instance of the class. /// @@ -20,6 +25,60 @@ public McpAuthenticationHandler( UrlEncoder encoder) : base(options, logger, encoder) { + _optionsMonitor = options; + } + + /// + public async Task HandleRequestAsync() + { + // Check if the request is for the resource metadata endpoint + string requestPath = Request.Path.Value ?? string.Empty; + var options = _optionsMonitor.CurrentValue; + string resourceMetadataPath = options.ResourceMetadataUri.ToString(); + + // If the path doesn't match, let the request continue through the pipeline + if (!string.Equals(requestPath, resourceMetadataPath, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // This is a request for resource metadata - handle it + await HandleResourceMetadataRequestAsync(); + return true; + } + + /// + /// Handles the resource metadata request. + /// + private async Task HandleResourceMetadataRequestAsync() + { + // Get a copy of the resource metadata from options to avoid modifying the original + var options = _optionsMonitor.CurrentValue; + var metadata = new ProtectedResourceMetadata + { + AuthorizationServers = [.. options.ResourceMetadata.AuthorizationServers], + BearerMethodsSupported = [.. options.ResourceMetadata.BearerMethodsSupported], + ScopesSupported = [.. options.ResourceMetadata.ScopesSupported], + ResourceDocumentation = options.ResourceMetadata.ResourceDocumentation + }; + + // Set default resource if not set + if (metadata.Resource == null) + { + var request = Request; + var hostString = request.Host.Value; + var scheme = request.Scheme; + metadata.Resource = new Uri($"{scheme}://{hostString}"); + } + + Response.StatusCode = StatusCodes.Status200OK; + Response.ContentType = "application/json"; + + var json = System.Text.Json.JsonSerializer.Serialize( + metadata, + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))); + + await Response.WriteAsync(json); } /// @@ -36,18 +95,29 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // Set the response status code Response.StatusCode = StatusCodes.Status401Unauthorized; + // Get the current options to ensure we have the latest values + var options = _optionsMonitor.CurrentValue; + // Generate the full resource metadata URL based on the current request var baseUrl = $"{Request.Scheme}://{Request.Host}"; - // Properly parse and validate the ResourceMetadataUri - if (!Uri.TryCreate(Options.ResourceMetadataUri.ToString(), UriKind.Absolute, out var prmDocumentUri)) - throw new InvalidOperationException("Invalid ResourceMetadataUri in options."); - - // Verify that the URI scheme starts with "http" - if (!prmDocumentUri.Scheme.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("ResourceMetadataUri must use HTTP or HTTPS scheme."); - - var rawPrmDocumentUri = prmDocumentUri.ToString(); + string resourceMetadataUriString = options.ResourceMetadataUri.ToString(); + string rawPrmDocumentUri; + + // Check if the URI is relative or absolute + if (options.ResourceMetadataUri.IsAbsoluteUri) + { + rawPrmDocumentUri = resourceMetadataUriString; + } + else + { + // For relative URIs, combine with the base URL + if (!Uri.TryCreate(baseUrl + resourceMetadataUriString, UriKind.Absolute, out var absoluteUri)) + { + throw new InvalidOperationException("Could not create absolute URI for resource metadata."); + } + rawPrmDocumentUri = absoluteUri.ToString(); + } // Initialize properties if null properties ??= new AuthenticationProperties(); diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs index e2d20f16..bcfadc06 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -40,36 +40,21 @@ public static AuthenticationBuilder AddMcp( string displayName, Action? configureOptions = null) { - // Create options instance to pass to ResourceMetadataService - var options = new McpAuthenticationOptions(); - configureOptions?.Invoke(options); - - // Register ResourceMetadataService with options - builder.Services.AddSingleton(sp => { - var service = new ResourceMetadataService(); - - // Configure the service with the resource metadata from options - service.ConfigureMetadata(metadata => { - metadata.Resource = options.ResourceMetadata.Resource; - metadata.AuthorizationServers = options.ResourceMetadata.AuthorizationServers; - metadata.BearerMethodsSupported = options.ResourceMetadata.BearerMethodsSupported; - metadata.ScopesSupported = options.ResourceMetadata.ScopesSupported; - metadata.ResourceDocumentation = options.ResourceMetadata.ResourceDocumentation; - }); - - return service; - }); - - builder.Services.TryAddSingleton(); + if (configureOptions != null) + { + if (authenticationScheme == McpAuthenticationDefaults.AuthenticationScheme) + { + builder.Services.Configure(configureOptions); + } + else + { + builder.Services.Configure(authenticationScheme, configureOptions); + } + } return builder.AddScheme( - authenticationScheme, - displayName, - opt => { - if (configureOptions != null) - { - configureOptions(opt); - } - }); + authenticationScheme, + displayName, + options => { }); // No-op to avoid overriding } } diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationMarker.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationMarker.cs deleted file mode 100644 index e4fe4702..00000000 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationMarker.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ModelContextProtocol.AspNetCore.Auth; - -/// -/// Marker class to indicate that MCP authorization has been configured. -/// -public class McpAuthorizationMarker { } diff --git a/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs deleted file mode 100644 index 1e90f4da..00000000 --- a/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataEndpointHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace ModelContextProtocol.AspNetCore.Auth; - -/// -/// Handles the resource metadata endpoint requests in an AOT-compatible way. -/// -internal sealed class ResourceMetadataEndpointHandler -{ - private readonly ResourceMetadataService _resourceMetadataService; - - public ResourceMetadataEndpointHandler(ResourceMetadataService resourceMetadataService) - { - _resourceMetadataService = resourceMetadataService; - } - - public Task HandleRequest(HttpContext httpContext) - { - var result = _resourceMetadataService.HandleResourceMetadataRequest(httpContext); - return result.ExecuteAsync(httpContext); - } -} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataService.cs b/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataService.cs deleted file mode 100644 index 37336187..00000000 --- a/src/ModelContextProtocol.AspNetCore/Auth/ResourceMetadataService.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Microsoft.AspNetCore.Http; -using ModelContextProtocol.Auth.Types; -using ModelContextProtocol.Utils.Json; - -namespace ModelContextProtocol.AspNetCore.Auth; - -/// -/// Service for managing MCP resource metadata. -/// -public class ResourceMetadataService -{ - private readonly ProtectedResourceMetadata _metadata = new(); - - /// - /// Configures the resource metadata. - /// - /// Configuration action. - public void ConfigureMetadata(Action configure) - { - configure(_metadata); - } - - /// - /// Gets the resource metadata. - /// - /// The resource metadata. - public ProtectedResourceMetadata GetMetadata() - { - return _metadata; - } - - /// - /// Handles the resource metadata request. - /// - /// The HTTP context. - /// An IResult containing the resource metadata. - public IResult HandleResourceMetadataRequest(HttpContext context) - { - var metadata = GetMetadata(); - - // Set default resource if not set - if (metadata.Resource == null) - { - var request = context.Request; - var hostString = request.Host.Value; - var scheme = request.Scheme; - metadata.Resource = new Uri($"{scheme}://{hostString}"); - } - - return Results.Json(metadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))); - } -} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index 0890a1a6..f7ae4ed7 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -52,24 +52,6 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo .WithMetadata(new AcceptsMetadata(["application/json"])) .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted)); - // Check if authentication/authorization is configured - var authMarker = endpoints.ServiceProvider.GetService(); - if (authMarker != null) - { - // Authorization is configured, so automatically map the OAuth protected resource endpoint - var resourceMetadataService = endpoints.ServiceProvider.GetRequiredService(); - - var handler = new ResourceMetadataEndpointHandler(resourceMetadataService); - - var options = endpoints.ServiceProvider.GetRequiredService>(); - var metadataPath = options.Value.ResourceMetadataUri.ToString(); - - sseGroup.MapGet(metadataPath, handler.HandleRequest) - .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["application/json"])) - .AllowAnonymous() - .WithDisplayName("MCP Resource Metadata"); - } - return mcpGroup; } } From e3c5c2152fc55ded1e1cf3ef56d2ca449f5ad4c8 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 13:17:07 -0700 Subject: [PATCH 054/128] Remove unused namespaces --- .../Auth/McpAuthorizationExtensions.cs | 1 - .../McpEndpointRouteBuilderExtensions.cs | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs index bcfadc06..7441b295 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.DependencyInjection.Extensions; using ModelContextProtocol.AspNetCore.Auth; namespace Microsoft.Extensions.DependencyInjection; diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index f7ae4ed7..0eefa52f 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -2,9 +2,7 @@ using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using ModelContextProtocol.AspNetCore; -using ModelContextProtocol.AspNetCore.Auth; using ModelContextProtocol.Protocol.Messages; using System.Diagnostics.CodeAnalysis; From d77368ded72d83b30798ea9f2b379fc20e0556b7 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 13:25:51 -0700 Subject: [PATCH 055/128] Placeholder for events --- .../Auth/McpAuthenticationEvents.cs | 9 +++++++++ .../Auth/McpAuthenticationOptions.cs | 9 +++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationEvents.cs diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationEvents.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationEvents.cs new file mode 100644 index 00000000..b1d59915 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationEvents.cs @@ -0,0 +1,9 @@ +namespace ModelContextProtocol.AspNetCore.Auth +{ + /// + /// Represents the authentication events for Model Context Protocol. + /// + public class McpAuthenticationEvents + { + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs index 836b91e0..38099000 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs @@ -10,6 +10,15 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions { private static readonly Uri DefaultResourceMetadataUri = new("/.well-known/oauth-protected-resource", UriKind.Relative); + /// + /// Gets or sets the events used to handle authentication events. + /// + public new McpAuthenticationEvents Events + { + get { return (McpAuthenticationEvents)base.Events!; } + set { base.Events = value; } + } + /// /// The URI to the resource metadata document. /// From 32f22655a71db4ae5d56fa28dde29c843d9ba7b9 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 13:57:06 -0700 Subject: [PATCH 056/128] Simplify policy setup --- samples/ProtectedMCPServer/Program.cs | 5 +-- .../Auth/McpAuthorizationPolicyExtensions.cs | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationPolicyExtensions.cs diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 52866501..0eb755fe 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -77,10 +77,7 @@ // Add authorization services builder.Services.AddAuthorization(options => { - options.AddPolicy(McpAuthenticationDefaults.AuthenticationScheme, policy => - { - policy.RequireAuthenticatedUser(); - }); + options.AddMcpPolicy(); }); // Configure MCP Server diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationPolicyExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationPolicyExtensions.cs new file mode 100644 index 00000000..016dbbde --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationPolicyExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Authorization; +using ModelContextProtocol.AspNetCore.Auth; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for adding MCP authorization policies to ASP.NET Core applications. +/// +public static class McpAuthorizationExtensions +{ + /// + /// Adds a preconfigured MCP policy to the authorization options. + /// + /// The authorization options. + /// The name of the policy to add. Default is . + /// An optional action to further configure the policy builder. + /// The authorization options for chaining. + public static AuthorizationOptions AddMcpPolicy( + this AuthorizationOptions options, + string policyName = McpAuthenticationDefaults.AuthenticationScheme, + Action? configurePolicy = null) + { + // Create a policy builder with default MCP configuration + var policyBuilder = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .AddAuthenticationSchemes(McpAuthenticationDefaults.AuthenticationScheme); + + // Allow additional configuration if provided + configurePolicy?.Invoke(policyBuilder); + + // Add the configured policy + options.AddPolicy(policyName, policyBuilder.Build()); + + return options; + } +} \ No newline at end of file From 67342cd28ffc65ae82eb9e5d773ae20d422639ff Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 14:09:58 -0700 Subject: [PATCH 057/128] Policy setup --- samples/ProtectedMCPServer/Program.cs | 5 +- .../Auth/McpAuthenticationHandler.cs | 60 ++++++++++--------- .../Auth/McpAuthorizationPolicyExtensions.cs | 34 +++++++++++ 3 files changed, 71 insertions(+), 28 deletions(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 0eb755fe..602ff730 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -77,7 +77,10 @@ // Add authorization services builder.Services.AddAuthorization(options => { - options.AddMcpPolicy(); + // Modify the MCP policy to include both MCP and JWT Bearer schemes + // This ensures the bearer token is properly authenticated while maintaining MCP for challenges + options.AddMcpPolicy(configurePolicy: builder => + builder.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)); }); // Configure MCP Server diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index 6cd70ec4..fff9fa38 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -47,6 +47,36 @@ public async Task HandleRequestAsync() return true; } + /// + /// Gets the base URL from the current request, including scheme, host, and path base. + /// + private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}"; + + /// + /// Gets the absolute URI for the resource metadata endpoint. + /// + private string GetAbsoluteResourceMetadataUri() + { + var options = _optionsMonitor.CurrentValue; + var resourceMetadataUri = options.ResourceMetadataUri; + + if (resourceMetadataUri.IsAbsoluteUri) + { + return resourceMetadataUri.ToString(); + } + + // For relative URIs, combine with the base URL + string baseUrl = GetBaseUrl(); + string resourceMetadataPath = resourceMetadataUri.ToString(); + + if (!Uri.TryCreate(baseUrl + resourceMetadataPath, UriKind.Absolute, out var absoluteUri)) + { + throw new InvalidOperationException("Could not create absolute URI for resource metadata."); + } + + return absoluteUri.ToString(); + } + /// /// Handles the resource metadata request. /// @@ -65,10 +95,7 @@ private async Task HandleResourceMetadataRequestAsync() // Set default resource if not set if (metadata.Resource == null) { - var request = Request; - var hostString = request.Host.Value; - var scheme = request.Scheme; - metadata.Resource = new Uri($"{scheme}://{hostString}"); + metadata.Resource = new Uri(GetBaseUrl()); } Response.StatusCode = StatusCodes.Status200OK; @@ -95,29 +122,8 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // Set the response status code Response.StatusCode = StatusCodes.Status401Unauthorized; - // Get the current options to ensure we have the latest values - var options = _optionsMonitor.CurrentValue; - - // Generate the full resource metadata URL based on the current request - var baseUrl = $"{Request.Scheme}://{Request.Host}"; - - string resourceMetadataUriString = options.ResourceMetadataUri.ToString(); - string rawPrmDocumentUri; - - // Check if the URI is relative or absolute - if (options.ResourceMetadataUri.IsAbsoluteUri) - { - rawPrmDocumentUri = resourceMetadataUriString; - } - else - { - // For relative URIs, combine with the base URL - if (!Uri.TryCreate(baseUrl + resourceMetadataUriString, UriKind.Absolute, out var absoluteUri)) - { - throw new InvalidOperationException("Could not create absolute URI for resource metadata."); - } - rawPrmDocumentUri = absoluteUri.ToString(); - } + // Get the absolute URI for the resource metadata + string rawPrmDocumentUri = GetAbsoluteResourceMetadataUri(); // Initialize properties if null properties ??= new AuthenticationProperties(); diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationPolicyExtensions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationPolicyExtensions.cs index 016dbbde..4d3991fb 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationPolicyExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationPolicyExtensions.cs @@ -33,4 +33,38 @@ public static AuthorizationOptions AddMcpPolicy( return options; } + + /// + /// Adds a preconfigured MCP policy that includes additional authentication schemes. + /// + /// The authorization options. + /// Additional authentication schemes to include in the policy. + /// The name of the policy to add. Default is . + /// An optional action to further configure the policy builder. + /// The authorization options for chaining. + public static AuthorizationOptions AddMcpPolicy( + this AuthorizationOptions options, + string[] additionalSchemes, + string policyName = McpAuthenticationDefaults.AuthenticationScheme, + Action? configurePolicy = null) + { + if (additionalSchemes == null || additionalSchemes.Length == 0) + { + return AddMcpPolicy(options, policyName, configurePolicy); + } + + // Create a policy builder with MCP and additional authentication schemes + var allSchemes = new[] { McpAuthenticationDefaults.AuthenticationScheme }.Concat(additionalSchemes).ToArray(); + + var policyBuilder = new AuthorizationPolicyBuilder(allSchemes) + .RequireAuthenticatedUser(); + + // Allow additional configuration if provided + configurePolicy?.Invoke(policyBuilder); + + // Add the configured policy + options.AddPolicy(policyName, policyBuilder.Build()); + + return options; + } } \ No newline at end of file From b37a7b9e34d1a3decc64365d92b954dce872a84e Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 14:21:30 -0700 Subject: [PATCH 058/128] Escaping --- .../Auth/McpAuthenticationHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index fff9fa38..13aea500 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -130,7 +130,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // Set the WWW-Authenticate header with the resource_metadata string headerValue = $"Bearer realm=\"{Scheme.Name}\""; - headerValue += $", resource_metadata=\"{rawPrmDocumentUri}\""; + headerValue += $", resource_metadata=\"{Uri.EscapeDataString(rawPrmDocumentUri)}\""; Response.Headers["WWW-Authenticate"] = headerValue; From 583de65f14c264a6beaf9c238b9f5a6d7d6ede23 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 14:35:50 -0700 Subject: [PATCH 059/128] Update server configuration --- samples/ProtectedMCPServer/Program.cs | 34 +++++++++++++------ .../Auth/McpAuthenticationHandler.cs | 16 +++++---- .../Auth/McpAuthenticationOptions.cs | 29 +++++++++++++++- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 602ff730..4e0be3e1 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using ModelContextProtocol.AspNetCore.Auth; +using ModelContextProtocol.Auth.Types; using ProtectedMCPServer.Tools; using System.Net.Http.Headers; using System.Security.Claims; @@ -67,11 +68,21 @@ }) .AddMcp(options => { - // Configure the MCP authentication with the same Entra ID server - options.ResourceMetadata.AuthorizationServers.Add(new Uri($"{instance}{tenantId}/v2.0")); - options.ResourceMetadata.BearerMethodsSupported.Add("header"); - options.ResourceMetadata.ScopesSupported.AddRange(["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"]); - options.ResourceMetadata.ResourceDocumentation = new Uri("https://docs.example.com/api/weather"); + options.ResourceMetadataProvider = context => + { + var metadata = new ProtectedResourceMetadata + { + BearerMethodsSupported = { "header" }, + ResourceDocumentation = new Uri("https://docs.example.com/api/weather"), + AuthorizationServers = { new Uri($"{instance}{tenantId}/v2.0") } + }; + + metadata.ScopesSupported.AddRange(new[] { + "api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read" + }); + + return metadata; + }; }); // Add authorization services @@ -104,15 +115,18 @@ Console.WriteLine("Starting MCP server with authorization at http://localhost:7071"); Console.WriteLine("PRM Document URL: http://localhost:7071/.well-known/oauth-protected-resource"); +Console.WriteLine(" - This endpoint returns different metadata based on the client type!"); +Console.WriteLine(" - Try with different User-Agent headers or add ?mobile query parameter"); Console.WriteLine(); Console.WriteLine("Entra ID (Azure AD) JWT token validation is configured"); Console.WriteLine(); -Console.WriteLine("To test the server:"); -Console.WriteLine("1. Use an MCP client that supports OAuth flow with Microsoft Entra ID"); -Console.WriteLine("2. The client should obtain a token for audience: api://weather-api"); -Console.WriteLine("3. The token should be issued by Microsoft Entra ID tenant: " + tenantId); -Console.WriteLine("4. Include this token in the Authorization header of requests"); +Console.WriteLine("To test the server with different client types:"); +Console.WriteLine("1. Standard client: No special headers needed"); +Console.WriteLine("2. Mobile client: Add 'mobile' in User-Agent or use ?mobile query parameter"); +Console.WriteLine("3. Partner client: Include 'partner' in User-Agent or add X-Partner-API header"); +Console.WriteLine(); +Console.WriteLine("Each client type will receive different authorization requirements!"); Console.WriteLine(); Console.WriteLine("Press Ctrl+C to stop the server"); diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs index 13aea500..5081aada 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs @@ -82,14 +82,18 @@ private string GetAbsoluteResourceMetadataUri() ///
private async Task HandleResourceMetadataRequestAsync() { - // Get a copy of the resource metadata from options to avoid modifying the original + // Get resource metadata from options, using the dynamic provider if available var options = _optionsMonitor.CurrentValue; + var resourceMetadata = options.GetResourceMetadata(Request.HttpContext); + + // Create a copy to avoid modifying the original var metadata = new ProtectedResourceMetadata { - AuthorizationServers = [.. options.ResourceMetadata.AuthorizationServers], - BearerMethodsSupported = [.. options.ResourceMetadata.BearerMethodsSupported], - ScopesSupported = [.. options.ResourceMetadata.ScopesSupported], - ResourceDocumentation = options.ResourceMetadata.ResourceDocumentation + AuthorizationServers = [.. resourceMetadata.AuthorizationServers], + BearerMethodsSupported = [.. resourceMetadata.BearerMethodsSupported], + ScopesSupported = [.. resourceMetadata.ScopesSupported], + ResourceDocumentation = resourceMetadata.ResourceDocumentation, + Resource = resourceMetadata.Resource }; // Set default resource if not set @@ -130,7 +134,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // Set the WWW-Authenticate header with the resource_metadata string headerValue = $"Bearer realm=\"{Scheme.Name}\""; - headerValue += $", resource_metadata=\"{Uri.EscapeDataString(rawPrmDocumentUri)}\""; + headerValue += $", resource_metadata=\"{rawPrmDocumentUri}\""; Response.Headers["WWW-Authenticate"] = headerValue; diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs index 38099000..23fc9b48 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; using ModelContextProtocol.Auth.Types; namespace ModelContextProtocol.AspNetCore.Auth; @@ -28,11 +29,37 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions public Uri ResourceMetadataUri { get; set; } = DefaultResourceMetadataUri; /// - /// Gets or sets the protected resource metadata. + /// Gets or sets the static protected resource metadata. /// /// /// This contains the OAuth metadata for the protected resource, including authorization servers, /// supported scopes, and other information needed for clients to authenticate. + /// This property is used when is not set. /// public ProtectedResourceMetadata ResourceMetadata { get; set; } = new ProtectedResourceMetadata(); + + /// + /// Gets or sets a delegate that dynamically provides resource metadata based on the HTTP context. + /// + /// + /// When set, this delegate will be called to generate resource metadata for each request, + /// allowing dynamic customization based on the caller or other contextual information. + /// This takes precedence over the static property. + /// + public Func? ResourceMetadataProvider { get; set; } + + /// + /// Gets the resource metadata for the current request. + /// + /// The HTTP context for the current request. + /// The resource metadata to use for the current request. + internal ProtectedResourceMetadata GetResourceMetadata(HttpContext context) + { + if (ResourceMetadataProvider != null) + { + return ResourceMetadataProvider(context); + } + + return ResourceMetadata; + } } \ No newline at end of file From 5f15bced947b90f16acb3c96d35ec3ad9c985a54 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 14:42:05 -0700 Subject: [PATCH 060/128] Validation. --- .../Auth/AuthorizationConfigExtensions.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs b/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs index 77b2c612..629e626c 100644 --- a/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs +++ b/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs @@ -60,11 +60,19 @@ public static AuthorizationConfig UseHttpListener( /// /// Default implementation to open a URL in the default browser. + /// Only allows http and https URLs to be opened for security reasons. /// private static Task DefaultOpenBrowser(string url) { try { + // Validate that the URL is using http or https protocol + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || + (uri.Scheme != "http" && uri.Scheme != "https")) + { + return Task.FromException(new ArgumentException("Only HTTP or HTTPS URLs can be opened.", nameof(url))); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // On Windows, use the built-in Process.Start for URLs From 7c335622db2b31cef91bf58588c1e5ed6e7a4ecd Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 15:11:30 -0700 Subject: [PATCH 061/128] Delegate Base64 encoding to a more performant implementation --- src/ModelContextProtocol/Auth/OAuthService.cs | 21 +-- .../Utils/Base64UrlHelpers.cs | 129 ++++++++++++++++++ 2 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 src/ModelContextProtocol/Utils/Base64UrlHelpers.cs diff --git a/src/ModelContextProtocol/Auth/OAuthService.cs b/src/ModelContextProtocol/Auth/OAuthService.cs index d90b0314..317b72be 100644 --- a/src/ModelContextProtocol/Auth/OAuthService.cs +++ b/src/ModelContextProtocol/Auth/OAuthService.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.Json; using ModelContextProtocol.Auth.Types; +using ModelContextProtocol.Utils; using ModelContextProtocol.Utils.Json; namespace ModelContextProtocol.Auth; @@ -54,14 +55,8 @@ public static string GenerateCodeVerifier() using var rng = RandomNumberGenerator.Create(); rng.GetBytes(bytes); - // Base64url encode the random bytes - var base64 = Convert.ToBase64String(bytes); - var base64Url = base64 - .Replace('+', '-') - .Replace('/', '_') - .Replace("=", ""); - - return base64Url; + // Use the optimized Base64UrlHelpers for encoding + return Base64UrlHelpers.Encode(bytes); } /// @@ -75,14 +70,8 @@ public static string GenerateCodeChallenge(string codeVerifier) using var sha256 = SHA256.Create(); var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); - // Base64url encode the hash - var base64 = Convert.ToBase64String(challengeBytes); - var base64Url = base64 - .Replace('+', '-') - .Replace('/', '_') - .Replace("=", ""); - - return base64Url; + // Use the optimized Base64UrlHelpers for encoding + return Base64UrlHelpers.Encode(challengeBytes); } /// diff --git a/src/ModelContextProtocol/Utils/Base64UrlHelpers.cs b/src/ModelContextProtocol/Utils/Base64UrlHelpers.cs new file mode 100644 index 00000000..1ed2f23f --- /dev/null +++ b/src/ModelContextProtocol/Utils/Base64UrlHelpers.cs @@ -0,0 +1,129 @@ +using System.Text; + +namespace ModelContextProtocol.Utils +{ + /// + /// Helper methods for Base64Url encoding and decoding. + /// Based on implementation from MSAL (https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/main/src/client/Microsoft.Identity.Client/Utils/Base64UrlHelpers.cs) + /// + internal static class Base64UrlHelpers + { + private const char base64UrlCharacter62 = '-'; + private const char base64UrlCharacter63 = '_'; + + /// + /// Encoding table for base64url encoding + /// + internal static readonly char[] s_base64Table = + { + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + 'a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + '0','1','2','3','4','5','6','7','8','9', + base64UrlCharacter62, + base64UrlCharacter63 + }; + + /// + /// Converts an array of bytes to a base64url encoded string. + /// + /// An array of 8-bit unsigned integers. + /// The string representation in base64url encoding of inArray. + public static string Encode(byte[] inArray) + { + if (inArray == null) + return string.Empty; + + return Encode(inArray, 0, inArray.Length); + } + + /// + /// Converts a subset of an array of 8-bit unsigned integers to its equivalent string representation that is encoded with base-64-url digits. + /// + /// An array of 8-bit unsigned integers. + /// An offset in inArray. + /// The number of elements of inArray to convert. + /// The string representation in base64url encoding of length elements of inArray, starting at position offset. + private static string Encode(byte[] inArray, int offset, int length) + { + _ = inArray ?? throw new ArgumentNullException(nameof(inArray)); + + if (length == 0) + return string.Empty; + + if (length < 0) + throw new ArgumentOutOfRangeException(nameof(length)); + + if (offset < 0 || inArray.Length < offset) + throw new ArgumentOutOfRangeException(nameof(offset)); + + if (inArray.Length < offset + length) + throw new ArgumentOutOfRangeException(nameof(length)); + + int lengthmod3 = length % 3; + int limit = offset + (length - lengthmod3); + char[] output = new char[(length + 2) / 3 * 4]; + char[] table = s_base64Table; + int i, j = 0; + + // Process three bytes at a time, each three bytes becomes four base64 characters + for (i = offset; i < limit; i += 3) + { + byte d0 = inArray[i]; + byte d1 = inArray[i + 1]; + byte d2 = inArray[i + 2]; + + output[j + 0] = table[d0 >> 2]; + output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; + output[j + 2] = table[((d1 & 0x0f) << 2) | (d2 >> 6)]; + output[j + 3] = table[d2 & 0x3f]; + j += 4; + } + + // Handle remaining bytes and padding + i = limit; + + switch (lengthmod3) + { + case 2: + { + byte d0 = inArray[i]; + byte d1 = inArray[i + 1]; + + output[j + 0] = table[d0 >> 2]; + output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; + output[j + 2] = table[(d1 & 0x0f) << 2]; + j += 3; + } + break; + + case 1: + { + byte d0 = inArray[i]; + + output[j + 0] = table[d0 >> 2]; + output[j + 1] = table[(d0 & 0x03) << 4]; + j += 2; + } + break; + + // Default or case 0: no further operations are needed. + } + + // Return the result without creating any additional string allocations + return new string(output, 0, j); + } + + /// + /// Encodes a string using base64url encoding. + /// + /// The string to encode. + /// Base64Url encoding of the UTF8 bytes of the input string. + public static string EncodeString(string str) + { + if (str == null) + throw new ArgumentNullException(nameof(str), "Input string cannot be null."); + + return Encode(Encoding.UTF8.GetBytes(str)) ?? string.Empty; + } + } +} \ No newline at end of file From 8d4c0c4611edb43586c8d46954b3f5b27c9ee08b Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 15:20:33 -0700 Subject: [PATCH 062/128] Make sure we're consistent --- src/ModelContextProtocol/Auth/OAuthService.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ModelContextProtocol/Auth/OAuthService.cs b/src/ModelContextProtocol/Auth/OAuthService.cs index 317b72be..ceecb49e 100644 --- a/src/ModelContextProtocol/Auth/OAuthService.cs +++ b/src/ModelContextProtocol/Auth/OAuthService.cs @@ -363,11 +363,12 @@ private string GenerateRandomString(int length) var bytes = new byte[length]; using var rng = RandomNumberGenerator.Create(); rng.GetBytes(bytes); - return Convert.ToBase64String(bytes) - .Replace('+', '-') - .Replace('/', '_') - .Replace("=", "") - .Substring(0, length); + + // Use the optimized Base64UrlHelpers for encoding + string base64Url = Base64UrlHelpers.Encode(bytes); + + // Ensure we return exactly the requested length + return base64Url.Substring(0, Math.Min(base64Url.Length, length)); } // This method would be used in a real implementation after receiving the authorization code From 55bb47122168724bf83c2c918d40eba1715a41c4 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 15:39:07 -0700 Subject: [PATCH 063/128] Implement refresh token logic --- src/ModelContextProtocol/Auth/OAuthService.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/ModelContextProtocol/Auth/OAuthService.cs b/src/ModelContextProtocol/Auth/OAuthService.cs index ceecb49e..f2fe046e 100644 --- a/src/ModelContextProtocol/Auth/OAuthService.cs +++ b/src/ModelContextProtocol/Auth/OAuthService.cs @@ -416,4 +416,82 @@ private async Task ExchangeAuthorizationCodeForTokenAsync( return tokenResponse; } + + /// + /// Refreshes an OAuth access token using a refresh token. + /// + /// The token endpoint URI from the authorization server metadata. + /// The client ID to use for authentication. + /// The client secret to use for authentication, if available. + /// The refresh token to use for obtaining a new access token. + /// Optional scopes to request. If not provided, the server will use the same scopes as the original token. + /// A new OAuth token response containing a new access token and potentially a new refresh token. + /// Thrown when required parameters are null. + /// Thrown when the token refresh fails. + public async Task RefreshAccessTokenAsync( + Uri tokenEndpoint, + string clientId, + string? clientSecret, + string refreshToken, + IEnumerable? scopes = null) + { + if (tokenEndpoint == null) throw new ArgumentNullException(nameof(tokenEndpoint)); + if (string.IsNullOrEmpty(clientId)) throw new ArgumentNullException(nameof(clientId)); + if (string.IsNullOrEmpty(refreshToken)) throw new ArgumentNullException(nameof(refreshToken)); + + var tokenRequest = new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["client_id"] = clientId + }; + + // Add scopes if provided + if (scopes != null) + { + tokenRequest["scope"] = string.Join(" ", scopes); + } + + var requestContent = new FormUrlEncodedContent(tokenRequest); + + HttpResponseMessage response; + if (!string.IsNullOrEmpty(clientSecret)) + { + // Add client authentication if secret is available + var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue); + response = await _httpClient.PostAsync(tokenEndpoint, requestContent); + _httpClient.DefaultRequestHeaders.Authorization = null; + } + else + { + response = await _httpClient.PostAsync(tokenEndpoint, requestContent); + } + + try + { + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var tokenResponse = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + if (tokenResponse == null) + { + throw new InvalidOperationException("Failed to parse token response."); + } + + // Some authorization servers might not return a new refresh token + // If no new refresh token is provided, keep the old one + if (string.IsNullOrEmpty(tokenResponse.RefreshToken)) + { + tokenResponse.RefreshToken = refreshToken; + } + + return tokenResponse; + } + catch (HttpRequestException ex) + { + string errorContent = await response.Content.ReadAsStringAsync(); + throw new InvalidOperationException($"Failed to refresh access token: {ex.Message}. Response: {errorContent}", ex); + } + } } \ No newline at end of file From debdb673f8d3fffd39b79a3a34330ad331bc1a57 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 18:07:33 -0700 Subject: [PATCH 064/128] Stripping out the client implementation - this needs to be re-thought --- samples/ProtectedMCPClient/Program.cs | 36 +- .../Auth/AuthorizationConfigExtensions.cs | 108 ---- .../Auth/McpClientExtensions.cs | 122 ----- .../Auth/OAuthDelegatingHandler.cs | 140 ----- src/ModelContextProtocol/Auth/OAuthHelpers.cs | 229 -------- src/ModelContextProtocol/Auth/OAuthService.cs | 497 ------------------ .../Auth/Types/AuthorizationCodeOptions.cs | 47 -- .../Auth/Types/AuthorizationConfig.cs | 32 -- .../Auth/Types/OAuthToken.cs | 45 -- .../Auth/Types/PkceValues.cs | 6 - .../Utils/Base64UrlHelpers.cs | 129 ----- .../Utils/Json/McpJsonUtilities.cs | 1 - 12 files changed, 18 insertions(+), 1374 deletions(-) delete mode 100644 src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs delete mode 100644 src/ModelContextProtocol/Auth/McpClientExtensions.cs delete mode 100644 src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs delete mode 100644 src/ModelContextProtocol/Auth/OAuthHelpers.cs delete mode 100644 src/ModelContextProtocol/Auth/OAuthService.cs delete mode 100644 src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs delete mode 100644 src/ModelContextProtocol/Auth/Types/AuthorizationConfig.cs delete mode 100644 src/ModelContextProtocol/Auth/Types/OAuthToken.cs delete mode 100644 src/ModelContextProtocol/Auth/Types/PkceValues.cs delete mode 100644 src/ModelContextProtocol/Utils/Base64UrlHelpers.cs diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index fd3646a5..5e554a55 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -14,26 +14,26 @@ static async Task Main(string[] args) Console.WriteLine("=================================================="); Console.WriteLine(); - // Create the authorization config with HTTP listener - var authConfig = new AuthorizationConfig - { - ClientId = "6ad97b5f-7a7b-413f-8603-7a3517d4adb8", - Scopes = ["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"] - }.UseHttpListener(hostname: "localhost", listenPort: 1170); + //// Create the authorization config with HTTP listener + //var authConfig = new AuthorizationConfig + //{ + // ClientId = "6ad97b5f-7a7b-413f-8603-7a3517d4adb8", + // Scopes = ["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"] + //}.UseHttpListener(hostname: "localhost", listenPort: 1170); - // Create an HTTP client with OAuth handling - var oauthHandler = new OAuthDelegatingHandler( - redirectUri: authConfig.RedirectUri, - clientId: authConfig.ClientId, - clientName: authConfig.ClientName, - scopes: authConfig.Scopes, - authorizationHandler: authConfig.AuthorizationHandler) - { - // The OAuth handler needs an inner handler - InnerHandler = new HttpClientHandler() - }; + //// Create an HTTP client with OAuth handling + //var oauthHandler = new OAuthDelegatingHandler( + // redirectUri: authConfig.RedirectUri, + // clientId: authConfig.ClientId, + // clientName: authConfig.ClientName, + // scopes: authConfig.Scopes, + // authorizationHandler: authConfig.AuthorizationHandler) + //{ + // // The OAuth handler needs an inner handler + // InnerHandler = new HttpClientHandler() + //}; - var httpClient = new HttpClient(oauthHandler); + var httpClient = new HttpClient(); var serverUrl = "http://localhost:7071/sse"; // Default server URL // Allow the user to specify a different server URL diff --git a/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs b/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs deleted file mode 100644 index 629e626c..00000000 --- a/src/ModelContextProtocol/Auth/AuthorizationConfigExtensions.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Threading.Tasks; - -namespace ModelContextProtocol.Auth; - -/// -/// Extension methods for . -/// -public static class AuthorizationConfigExtensions -{ - /// - /// Configures the authorization config to use an HTTP listener for the OAuth authorization code flow. - /// - /// The authorization configuration to modify. - /// Optional function to open a browser. If not provided, a default implementation will be used. - /// The hostname to listen on. Defaults to "localhost". - /// The port to listen on. Defaults to 8888. - /// The redirect path for the HTTP listener. Defaults to "/callback". - /// The modified authorization configuration for chaining. - /// - /// - /// This method configures the authorization configuration to use an HTTP listener for the OAuth - /// authorization code flow. When authorization is required, the listener will automatically: - /// - /// - /// Start an HTTP listener on the specified hostname and port - /// Open the user's browser to the authorization URL - /// Wait for the authorization code to be received via the redirect URI - /// Return the authorization code to the SDK to complete the flow - /// - /// - /// This provides a seamless authorization experience without requiring manual user intervention - /// to copy/paste authorization codes. - /// - /// - public static AuthorizationConfig UseHttpListener( - this AuthorizationConfig config, - Func? openBrowser = null, - string hostname = "localhost", - int listenPort = 8888, - string redirectPath = "/callback") - { - // Set the redirect URI - config.RedirectUri = new Uri($"http://{hostname}:{listenPort}{redirectPath}"); - - // Use default browser-opening implementation if none provided - openBrowser ??= DefaultOpenBrowser; - - // Configure the handler - config.AuthorizationHandler = OAuthHelpers.CreateHttpListenerCallback( - openBrowser, - hostname, - listenPort, - redirectPath); - - return config; - } - - /// - /// Default implementation to open a URL in the default browser. - /// Only allows http and https URLs to be opened for security reasons. - /// - private static Task DefaultOpenBrowser(string url) - { - try - { - // Validate that the URL is using http or https protocol - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || - (uri.Scheme != "http" && uri.Scheme != "https")) - { - return Task.FromException(new ArgumentException("Only HTTP or HTTPS URLs can be opened.", nameof(url))); - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // On Windows, use the built-in Process.Start for URLs - Process.Start(new ProcessStartInfo - { - FileName = url, - UseShellExecute = true - }); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - // On Linux, use xdg-open - Process.Start("xdg-open", url); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - // On macOS, use open - Process.Start("open", url); - } - else - { - // Fallback for other platforms - throw new NotSupportedException("Automatic browser opening is not supported on this platform."); - } - - return Task.CompletedTask; - } - catch (Exception ex) - { - return Task.FromException(new InvalidOperationException($"Failed to open browser: {ex.Message}", ex)); - } - } -} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/McpClientExtensions.cs b/src/ModelContextProtocol/Auth/McpClientExtensions.cs deleted file mode 100644 index 2185e334..00000000 --- a/src/ModelContextProtocol/Auth/McpClientExtensions.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Net.Http.Headers; -using System.Collections.Concurrent; -using ModelContextProtocol.Auth.Types; - -namespace ModelContextProtocol.Auth; - -/// -/// Provides extension methods for MCP clients to handle authentication. -/// -public static class McpClientExtensions -{ - // Store client configuration data in a static dictionary - private static readonly ConcurrentDictionary _clientConfigs = new(); - - /// - /// Attaches an OAuth token to the HTTP request. - /// - /// The HTTP client. - /// The OAuth token. - public static void AttachToken(this HttpClient httpClient, string token) - { - if (string.IsNullOrEmpty(token)) - { - throw new ArgumentNullException(nameof(token)); - } - - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - } - - /// - /// Configures a client to handle authorization challenges automatically. - /// - /// The HTTP client. - /// The URI to redirect to after authentication. - /// The client ID to use for authentication, or null to register a new client. - /// The client name to use for registration. - /// The requested scopes. - /// The handler to invoke when authorization is required. - public static void ConfigureAuthorizationHandler( - this HttpClient httpClient, - Uri redirectUri, - string? clientId = null, - string? clientName = null, - IEnumerable? scopes = null, - Func>? handler = null) - { - // Store authorization parameters for the HttpClient - var config = new AuthorizationConfig - { - RedirectUri = redirectUri, - ClientId = clientId, - ClientName = clientName, - Scopes = scopes?.ToList(), - AuthorizationHandler = handler - }; - - _clientConfigs[httpClient] = config; - } - - /// - /// Gets the authorization configuration for the HTTP client. - /// - /// The HTTP client. - /// The authorization configuration, or null if not configured. - public static AuthorizationConfig? GetAuthorizationConfig(this HttpClient httpClient) - { - _clientConfigs.TryGetValue(httpClient, out var config); - return config; - } - - /// - /// Handles a 401 Unauthorized response from an MCP server. - /// - /// The HTTP client. - /// The HTTP response with the 401 status code. - /// The OAuth token response if authentication was successful. - public static async Task HandleUnauthorizedResponseAsync( - this HttpClient httpClient, - HttpResponseMessage response) - { - if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized) - { - throw new ArgumentException("The response status code must be 401 Unauthorized.", nameof(response)); - } - - // Get the WWW-Authenticate header - var wwwAuthenticateHeader = response.Headers.WwwAuthenticate.ToString(); - if (string.IsNullOrEmpty(wwwAuthenticateHeader)) - { - throw new InvalidOperationException("The response does not contain a WWW-Authenticate header."); - } - - // Get the authorization configuration - var config = httpClient.GetAuthorizationConfig(); - if (config == null) - { - throw new InvalidOperationException("The HTTP client has not been configured for authorization handling. Call ConfigureAuthorizationHandler() first."); - } - - // Create OAuthAuthenticationService - use appropriate constructor based on whether we have a handler - OAuthService authService = config.AuthorizationHandler != null - ? new OAuthService(config.AuthorizationHandler) - : new OAuthService(); - - // Get resource URI - var resourceUri = response.RequestMessage?.RequestUri ?? throw new InvalidOperationException("Request URI is not available."); - - // Start the authentication flow - var tokenResponse = await authService.HandleAuthenticationAsync( - resourceUri, - wwwAuthenticateHeader, - config.RedirectUri, - config.ClientId, - config.ClientName, - config.Scopes); - - // Attach the access token to future requests - httpClient.AttachToken(tokenResponse.AccessToken); - - return tokenResponse; - } -} diff --git a/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs b/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs deleted file mode 100644 index e37d0313..00000000 --- a/src/ModelContextProtocol/Auth/OAuthDelegatingHandler.cs +++ /dev/null @@ -1,140 +0,0 @@ -using ModelContextProtocol.Auth.Types; -using System.Net; -using System.Net.Http.Headers; - -namespace ModelContextProtocol.Auth; - -/// -/// A delegating handler that automatically handles OAuth authentication for MCP clients. -/// -public class OAuthDelegatingHandler : DelegatingHandler -{ - private readonly Uri _redirectUri; - private readonly string? _clientId; - private readonly string? _clientName; - private readonly IEnumerable? _scopes; - private readonly Func>? _authorizationHandler; - private OAuthToken? _tokenResponse; - - /// - /// Initializes a new instance of the class. - /// - /// The URI to redirect to after authentication. - /// The client ID to use for authentication, or null to register a new client. - /// The client name to use for registration. - /// The requested scopes. - /// A handler to invoke when authorization is required. - public OAuthDelegatingHandler( - Uri redirectUri, - string? clientId = null, - string? clientName = null, - IEnumerable? scopes = null, - Func>? authorizationHandler = null) - { - _redirectUri = redirectUri; - _clientId = clientId; - _clientName = clientName; - _scopes = scopes; - _authorizationHandler = authorizationHandler; - } - - /// - /// Initializes a new instance of the class with an inner handler. - /// - /// The inner handler which processes the HTTP response messages. - /// The URI to redirect to after authentication. - /// The client ID to use for authentication, or null to register a new client. - /// The client name to use for registration. - /// The requested scopes. - /// A handler to invoke when authorization is required. - public OAuthDelegatingHandler( - HttpMessageHandler innerHandler, - Uri redirectUri, - string? clientId = null, - string? clientName = null, - IEnumerable? scopes = null, - Func>? authorizationHandler = null) - : base(innerHandler) - { - _redirectUri = redirectUri; - _clientId = clientId; - _clientName = clientName; - _scopes = scopes; - _authorizationHandler = authorizationHandler; - } - - /// - /// Manually set an OAuth token to use for subsequent requests. - /// - /// The OAuth token response. - public void SetToken(OAuthToken tokenResponse) - { - _tokenResponse = tokenResponse; - } - - /// - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - // If we have a token, attach it to the request - if (_tokenResponse != null) - { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _tokenResponse.AccessToken); - } - - // Send the request - var response = await base.SendAsync(request, cancellationToken); - - // If the response is 401 Unauthorized, try to authenticate - if (response.StatusCode == HttpStatusCode.Unauthorized) - { - // Create a temporary HttpClient to handle the authentication - // We need to use a new client to avoid infinite recursion - using var httpClient = new HttpClient(); - httpClient.ConfigureAuthorizationHandler( - _redirectUri, - _clientId, - _clientName, - _scopes, - _authorizationHandler); - - try - { - // Handle the 401 response - var authResponse = await httpClient.HandleUnauthorizedResponseAsync(response); - _tokenResponse = authResponse; // Now using a non-nullable intermediate variable - - // If we have a token, retry the original request with the token - // Create a new request (the original request has already been sent) - var newRequest = new HttpRequestMessage - { - Method = request.Method, - RequestUri = request.RequestUri, - Content = request.Content, - Headers = { - Authorization = new AuthenticationHeaderValue("Bearer", _tokenResponse.AccessToken) - } - }; - - // Copy other headers - foreach (var header in request.Headers) - { - if (header.Key != "Authorization") - { - newRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); - } - } - - // Send the request again - return await base.SendAsync(newRequest, cancellationToken); - } - catch (Exception) - { - // If authentication fails, return the original 401 response - } - } - - return response; - } -} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/OAuthHelpers.cs b/src/ModelContextProtocol/Auth/OAuthHelpers.cs deleted file mode 100644 index 62c6f2cf..00000000 --- a/src/ModelContextProtocol/Auth/OAuthHelpers.cs +++ /dev/null @@ -1,229 +0,0 @@ -using ModelContextProtocol.Auth.Types; -using ModelContextProtocol.Utils.Json; -using System.Net; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; - -namespace ModelContextProtocol.Auth; - -/// -/// Provides helper methods for handling OAuth authorization. -/// -public static class OAuthHelpers -{ - private static readonly HttpClient _httpClient = new(); - - /// - /// Creates an HTTP listener callback for handling OAuth 2.0 authorization code flow. - /// - /// A function that opens a browser with the given URL. - /// The hostname to listen on. Defaults to "localhost". - /// The port to listen on. Defaults to 8888. - /// The redirect path for the HTTP listener. Defaults to "/callback". - /// - /// A function that takes an authorization URI and returns a task that resolves to the authorization code. - /// - public static Func> CreateHttpListenerCallback( - Func openBrowser, - string hostname = "localhost", - int listenPort = 8888, - string redirectPath = "/callback") - { - return async authorizationUri => - { - // Ensure the path has a trailing slash for the HttpListener prefix - var listenerPrefix = $"http://{hostname}:{listenPort}{redirectPath.TrimEnd('/')}/"; - - using var listener = new HttpListener(); - listener.Prefixes.Add(listenerPrefix); - - try - { - listener.Start(); - } - catch (HttpListenerException ex) - { - throw new InvalidOperationException($"Failed to start HTTP listener on {listenerPrefix}: {ex.Message}"); - } - - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - - await openBrowser(authorizationUri.ToString()); - - try - { - var contextTask = listener.GetContextAsync(); - var completedTask = await Task.WhenAny(contextTask, Task.Delay(Timeout.Infinite, cts.Token)); - - if (completedTask != contextTask) - { - throw new InvalidOperationException("Authorization timed out after 5 minutes."); - } - - var context = await contextTask; - return ProcessCallback(context); - } - finally - { - listener.Stop(); - } - }; - } - - /// - /// Processes the HTTP callback and extracts the authorization code. - /// - private static string ProcessCallback(HttpListenerContext context) - { - var request = context.Request; - var response = context.Response; - - string? code = request.QueryString["code"]; - string? error = request.QueryString["error"]; - string html; - - if (!string.IsNullOrEmpty(error)) - { - html = $"

Authorization Failed

Error: {WebUtility.HtmlEncode(error)}

"; - SendResponse(response, html); - throw new InvalidOperationException($"Authorization failed: {error}"); - } - - if (string.IsNullOrEmpty(code)) - { - html = "

Authorization Failed

No authorization code received.

"; - SendResponse(response, html); - throw new InvalidOperationException("No authorization code received"); - } - - html = "

Authorization Successful

You may now close this window.

"; - SendResponse(response, html); - return code; - } - - /// - /// Sends an HTML response to the browser. - /// - private static void SendResponse(HttpListenerResponse response, string html) - { - try - { - byte[] buffer = Encoding.UTF8.GetBytes(html); - response.ContentType = "text/html"; - response.ContentLength64 = buffer.Length; - response.OutputStream.Write(buffer, 0, buffer.Length); - - // IMPORTANT: Explicitly close the response to ensure it's fully sent - response.Close(); - } - catch - { - // Silently handle errors - we're already in an error handling path - // and can't throw further exceptions or log to the console in a library - // TODO: Need a better implementation here. - } - } - - /// - /// Exchanges an authorization code for an OAuth token. - /// - /// The token endpoint URI. - /// The client ID. - /// The client secret, if any. - /// The redirect URI used in the authorization request. - /// The authorization code received from the authorization server. - /// The PKCE code verifier. - /// A cancellation token to cancel the operation. - /// The OAuth token response. - public static async Task ExchangeAuthorizationCodeForTokenAsync( - Uri tokenEndpoint, - string clientId, - string? clientSecret, - Uri redirectUri, - string authorizationCode, - string codeVerifier, - CancellationToken cancellationToken = default) - { - var tokenRequest = new Dictionary - { - ["grant_type"] = "authorization_code", - ["code"] = authorizationCode, - ["redirect_uri"] = redirectUri.ToString(), - ["client_id"] = clientId, - ["code_verifier"] = codeVerifier - }; - - var requestContent = new FormUrlEncodedContent(tokenRequest); - - HttpResponseMessage response; - if (!string.IsNullOrEmpty(clientSecret)) - { - // Add client authentication if secret is available - var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); - using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) - { - Content = requestContent - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authValue); - response = await _httpClient.SendAsync(request, cancellationToken); - } - else - { - response = await _httpClient.PostAsync(tokenEndpoint, requestContent, cancellationToken); - } - - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(cancellationToken); - var tokenResponse = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - if (tokenResponse == null) - { - throw new InvalidOperationException("Failed to parse token response."); - } - - return tokenResponse; - } - - /// - /// Creates a complete OAuth authorization code flow handler that automatically exchanges the code for a token. - /// - /// The token endpoint URI. - /// The client ID. - /// The client secret, if any. - /// The redirect URI used in the authorization request. - /// The PKCE code verifier. - /// A function that opens a browser with the given URL. - /// The hostname to listen on. Defaults to "localhost". - /// The port to listen on. Defaults to 8888. - /// The redirect path for the HTTP listener. Defaults to "/callback". - /// A function that takes an authorization URI and returns a task that resolves to the OAuth token. - public static Func> CreateCompleteOAuthFlowHandler( - Uri tokenEndpoint, - string clientId, - string? clientSecret, - Uri redirectUri, - string codeVerifier, - Func openBrowser, - string hostname = "localhost", - int listenPort = 8888, - string redirectPath = "/callback") - { - var codeHandler = CreateHttpListenerCallback(openBrowser, hostname, listenPort, redirectPath); - - return async (authorizationUri) => - { - // First get the authorization code - string authorizationCode = await codeHandler(authorizationUri); - - // Then exchange it for a token - return await ExchangeAuthorizationCodeForTokenAsync( - tokenEndpoint, - clientId, - clientSecret, - redirectUri, - authorizationCode, - codeVerifier); - }; - } -} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/OAuthService.cs b/src/ModelContextProtocol/Auth/OAuthService.cs deleted file mode 100644 index f2fe046e..00000000 --- a/src/ModelContextProtocol/Auth/OAuthService.cs +++ /dev/null @@ -1,497 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using ModelContextProtocol.Auth.Types; -using ModelContextProtocol.Utils; -using ModelContextProtocol.Utils.Json; - -namespace ModelContextProtocol.Auth; - -/// -/// Provides functionality for OAuth authentication in MCP clients. -/// -public partial class OAuthService -{ - private static readonly HttpClient _httpClient = new(); - private readonly Func>? _authorizationHandler; - - /// - /// Initializes a new instance of the class. - /// - public OAuthService() - { - } - - /// - /// Initializes a new instance of the class with an authorization handler. - /// - /// A handler to invoke when authorization is required. - public OAuthService(Func> authorizationHandler) - { - _authorizationHandler = authorizationHandler ?? throw new ArgumentNullException(nameof(authorizationHandler)); - } - - /// - /// Generates new PKCE values. - /// - /// A instance containing the code verifier and challenge. - public static PkceValues GeneratePkceValues() - { - var codeVerifier = GenerateCodeVerifier(); - var codeChallenge = GenerateCodeChallenge(codeVerifier); - return new PkceValues(codeVerifier, codeChallenge); - } - - /// - /// Generates a cryptographically random code verifier for PKCE. - /// - /// A base64url encoded string to be used as the code verifier. - public static string GenerateCodeVerifier() - { - // Generate a cryptographically random code verifier - var bytes = new byte[64]; - using var rng = RandomNumberGenerator.Create(); - rng.GetBytes(bytes); - - // Use the optimized Base64UrlHelpers for encoding - return Base64UrlHelpers.Encode(bytes); - } - - /// - /// Generates a code challenge from a code verifier using the S256 method. - /// - /// The code verifier to generate the challenge from. - /// A base64url encoded SHA256 hash of the code verifier. - public static string GenerateCodeChallenge(string codeVerifier) - { - // Create code challenge using S256 method - using var sha256 = SHA256.Create(); - var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); - - // Use the optimized Base64UrlHelpers for encoding - return Base64UrlHelpers.Encode(challengeBytes); - } - - /// - /// Handles the OAuth authentication flow when a 401 Unauthorized response is received. - /// - /// The URI of the resource being accessed. - /// The WWW-Authenticate header from the 401 response. - /// The URI to redirect to after authentication. - /// The client ID to use for authentication, or null to register a new client. - /// The client name to use for registration. - /// The requested scopes. - /// A handler to invoke when authorization is required. If not provided, the handler from the constructor will be used. - /// The OAuth token response. - public async Task HandleAuthenticationAsync( - Uri resourceUri, - string wwwAuthenticateHeader, - Uri redirectUri, - string? clientId = null, - string? clientName = null, - IEnumerable? scopes = null, - Func>? authorizationHandler = null) - { - // Use the provided authorization handler or fall back to the one from the constructor - var effectiveAuthHandler = authorizationHandler ?? _authorizationHandler; - - // Extract resource metadata URL from WWW-Authenticate header - var resourceMetadataUri = ExtractResourceMetadataUri(wwwAuthenticateHeader); - if (resourceMetadataUri == null) - { - throw new InvalidOperationException("Resource metadata URI not found in WWW-Authenticate header."); - } - - // Get resource metadata - var resourceMetadata = await GetResourceMetadataAsync(resourceMetadataUri); - - // Verify that the resource in the metadata matches the server's FQDN - VerifyResourceUri(resourceUri, resourceMetadata.Resource); - - // Get the first authorization server - if (resourceMetadata.AuthorizationServers.Count == 0) - { - throw new InvalidOperationException("No authorization servers found in resource metadata."); - } - - var authServerUri = resourceMetadata.AuthorizationServers[0]; - - // Get authorization server metadata - var authServerMetadata = await DiscoverAuthorizationServerMetadataAsync(authServerUri); - - // Register client if needed - string effectiveClientId; - string? clientSecret = null; - - if (string.IsNullOrEmpty(clientId) && authServerMetadata.RegistrationEndpoint != null) - { - var registrationResponse = await RegisterClientAsync( - authServerMetadata.RegistrationEndpoint, - redirectUri, - clientName ?? "MCP Client", - scopes); - - effectiveClientId = registrationResponse.ClientId; - clientSecret = registrationResponse.ClientSecret; - } - else if (string.IsNullOrEmpty(clientId)) - { - throw new InvalidOperationException("Client ID not provided and registration endpoint not available."); - } - else - { - // We know clientId is not null or empty at this point, but the compiler doesn't - // so we need to use the null-forgiving operator - effectiveClientId = clientId!; - } - - // Perform authorization code flow with PKCE - var tokenResponse = await PerformAuthorizationCodeFlowAsync( - authServerMetadata, - effectiveClientId, // This is now guaranteed to be non-null - clientSecret, - redirectUri, - scopes?.ToList() ?? resourceMetadata.ScopesSupported, - effectiveAuthHandler); - - return tokenResponse; - } - - private Uri? ExtractResourceMetadataUri(string wwwAuthenticateHeader) - { - if (string.IsNullOrEmpty(wwwAuthenticateHeader)) - { - return null; - } - - // Parse the WWW-Authenticate header to extract the resource_metadata parameter - if (wwwAuthenticateHeader.Contains("resource_metadata=")) - { - var resourceMetadataStart = wwwAuthenticateHeader.IndexOf("resource_metadata=") + "resource_metadata=".Length; - var resourceMetadataEnd = wwwAuthenticateHeader.IndexOf("\"", resourceMetadataStart + 1); - if (resourceMetadataEnd > resourceMetadataStart) - { - var resourceMetadataUri = wwwAuthenticateHeader.Substring(resourceMetadataStart + 1, resourceMetadataEnd - resourceMetadataStart - 1); - return new Uri(resourceMetadataUri); - } - } - - return null; - } - - private async Task GetResourceMetadataAsync(Uri resourceMetadataUri) - { - var response = await _httpClient.GetAsync(resourceMetadataUri); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(); - var resourceMetadata = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - if (resourceMetadata == null) - { - throw new InvalidOperationException("Failed to parse resource metadata."); - } - - return resourceMetadata; - } - - private void VerifyResourceUri(Uri resourceUri, Uri metadataResourceUri) - { - // Verify that the resource in the metadata matches the server's FQDN - if (!(Uri.Compare(resourceUri, metadataResourceUri, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0)) - { - throw new InvalidOperationException($"Resource URI in metadata ({metadataResourceUri}) does not match the server URI ({resourceUri})."); - } - } - - private async Task DiscoverAuthorizationServerMetadataAsync(Uri authServerUri) - { - // Ensure the authServerUri ends with a trailing slash - var baseUri = authServerUri.AbsoluteUri.EndsWith("/") - ? authServerUri - : new Uri(authServerUri.AbsoluteUri + "/"); - - // Now combine with the well-known endpoints - var openIdConfigUri = new Uri(baseUri, ".well-known/openid-configuration"); - var oauthConfigUri = new Uri(baseUri, ".well-known/oauth-authorization-server"); - - // Try OpenID Connect configuration endpoint first - try - { - var response = await _httpClient.GetAsync(openIdConfigUri); - if (response.IsSuccessStatusCode) - { - var json = await response.Content.ReadAsStringAsync(); - var metadata = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - if (metadata != null) - { - return metadata; - } - } - } - catch (Exception) - { - // Try next endpoint - } - - // Try OAuth 2.0 authorization server metadata endpoint - try - { - var response = await _httpClient.GetAsync(oauthConfigUri); - if (response.IsSuccessStatusCode) - { - var json = await response.Content.ReadAsStringAsync(); - var metadata = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - if (metadata != null) - { - return metadata; - } - } - } - catch (Exception) - { - // No more endpoints to try - } - - throw new InvalidOperationException("Could not discover authorization server metadata. Neither OpenID Connect nor OAuth 2.0 well-known endpoints returned valid metadata."); - } - - private async Task RegisterClientAsync(Uri registrationEndpoint, Uri redirectUri, string clientName, IEnumerable? scopes) - { - var request = new ClientRegistrationRequest - { - RedirectUris = new List { redirectUri.ToString() }, - ClientName = clientName, - TokenEndpointAuthMethod = "client_secret_basic", - GrantTypes = new List { "authorization_code", "refresh_token" }, - ResponseTypes = new List { "code" }, - Scope = scopes != null ? string.Join(" ", scopes) : null - }; - - var json = JsonSerializer.Serialize(request, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync(registrationEndpoint, content); - response.EnsureSuccessStatusCode(); - - var responseJson = await response.Content.ReadAsStringAsync(); - var registrationResponse = JsonSerializer.Deserialize(responseJson, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - if (registrationResponse == null) - { - throw new InvalidOperationException("Failed to parse client registration response."); - } - - return registrationResponse; - } - - private async Task PerformAuthorizationCodeFlowAsync( - AuthorizationServerMetadata authServerMetadata, - string clientId, - string? clientSecret, - Uri redirectUri, - IEnumerable scopes, - Func>? authorizationHandler) - { - // Generate PKCE values using our public method - var pkceValues = GeneratePkceValues(); - - // Build authorization URL - var authorizationUrl = BuildAuthorizationUrl( - authServerMetadata.AuthorizationEndpoint, - clientId, - redirectUri, - pkceValues.CodeChallenge, - scopes); - - // Check if an authorization handler is available - if (authorizationHandler != null) - { - try - { - // Get the authorization code using the provided handler - string authorizationCode = await authorizationHandler(new Uri(authorizationUrl)); - - // Exchange the authorization code for a token - return await ExchangeAuthorizationCodeForTokenAsync( - authServerMetadata.TokenEndpoint, - clientId, - clientSecret, - redirectUri, - authorizationCode, - pkceValues.CodeVerifier); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to complete OAuth authorization flow: {ex.Message}", ex); - } - } - - // No authorization handler available, throw with instructions - throw new NotImplementedException( - $"Authorization requires user interaction. Please direct the user to: {authorizationUrl}\n" + - $"After authorization, the user will be redirected to: {redirectUri}?code=[authorization_code]\n" + - $"You need to handle this redirect and extract the authorization code to complete the flow."); - } - - private string BuildAuthorizationUrl( - Uri authorizationEndpoint, - string clientId, - Uri redirectUri, - string codeChallenge, - IEnumerable scopes) - { - var scopeString = string.Join(" ", scopes); - - var queryParams = new Dictionary - { - ["client_id"] = clientId, - ["response_type"] = "code", - ["redirect_uri"] = redirectUri.ToString(), - ["scope"] = scopeString, - ["code_challenge"] = codeChallenge, - ["code_challenge_method"] = "S256", - ["state"] = GenerateRandomString(16) // Used for CSRF protection - }; - - var queryString = string.Join("&", queryParams.Select(p => $"{WebUtility.UrlEncode(p.Key)}={WebUtility.UrlEncode(p.Value)}")); - return $"{authorizationEndpoint}?{queryString}"; - } - - private string GenerateRandomString(int length) - { - var bytes = new byte[length]; - using var rng = RandomNumberGenerator.Create(); - rng.GetBytes(bytes); - - // Use the optimized Base64UrlHelpers for encoding - string base64Url = Base64UrlHelpers.Encode(bytes); - - // Ensure we return exactly the requested length - return base64Url.Substring(0, Math.Min(base64Url.Length, length)); - } - - // This method would be used in a real implementation after receiving the authorization code - private async Task ExchangeAuthorizationCodeForTokenAsync( - Uri tokenEndpoint, - string clientId, - string? clientSecret, - Uri redirectUri, - string authorizationCode, - string codeVerifier) - { - var tokenRequest = new Dictionary - { - ["grant_type"] = "authorization_code", - ["code"] = authorizationCode, - ["redirect_uri"] = redirectUri.ToString(), - ["client_id"] = clientId, - ["code_verifier"] = codeVerifier - }; - - var requestContent = new FormUrlEncodedContent(tokenRequest); - - HttpResponseMessage response; - if (!string.IsNullOrEmpty(clientSecret)) - { - // Add client authentication if secret is available - var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue); - response = await _httpClient.PostAsync(tokenEndpoint, requestContent); - _httpClient.DefaultRequestHeaders.Authorization = null; - } - else - { - response = await _httpClient.PostAsync(tokenEndpoint, requestContent); - } - - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(); - var tokenResponse = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - if (tokenResponse == null) - { - throw new InvalidOperationException("Failed to parse token response."); - } - - return tokenResponse; - } - - /// - /// Refreshes an OAuth access token using a refresh token. - /// - /// The token endpoint URI from the authorization server metadata. - /// The client ID to use for authentication. - /// The client secret to use for authentication, if available. - /// The refresh token to use for obtaining a new access token. - /// Optional scopes to request. If not provided, the server will use the same scopes as the original token. - /// A new OAuth token response containing a new access token and potentially a new refresh token. - /// Thrown when required parameters are null. - /// Thrown when the token refresh fails. - public async Task RefreshAccessTokenAsync( - Uri tokenEndpoint, - string clientId, - string? clientSecret, - string refreshToken, - IEnumerable? scopes = null) - { - if (tokenEndpoint == null) throw new ArgumentNullException(nameof(tokenEndpoint)); - if (string.IsNullOrEmpty(clientId)) throw new ArgumentNullException(nameof(clientId)); - if (string.IsNullOrEmpty(refreshToken)) throw new ArgumentNullException(nameof(refreshToken)); - - var tokenRequest = new Dictionary - { - ["grant_type"] = "refresh_token", - ["refresh_token"] = refreshToken, - ["client_id"] = clientId - }; - - // Add scopes if provided - if (scopes != null) - { - tokenRequest["scope"] = string.Join(" ", scopes); - } - - var requestContent = new FormUrlEncodedContent(tokenRequest); - - HttpResponseMessage response; - if (!string.IsNullOrEmpty(clientSecret)) - { - // Add client authentication if secret is available - var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue); - response = await _httpClient.PostAsync(tokenEndpoint, requestContent); - _httpClient.DefaultRequestHeaders.Authorization = null; - } - else - { - response = await _httpClient.PostAsync(tokenEndpoint, requestContent); - } - - try - { - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(); - var tokenResponse = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - if (tokenResponse == null) - { - throw new InvalidOperationException("Failed to parse token response."); - } - - // Some authorization servers might not return a new refresh token - // If no new refresh token is provided, keep the old one - if (string.IsNullOrEmpty(tokenResponse.RefreshToken)) - { - tokenResponse.RefreshToken = refreshToken; - } - - return tokenResponse; - } - catch (HttpRequestException ex) - { - string errorContent = await response.Content.ReadAsStringAsync(); - throw new InvalidOperationException($"Failed to refresh access token: {ex.Message}. Response: {errorContent}", ex); - } - } -} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs b/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs deleted file mode 100644 index 09eb49f6..00000000 --- a/src/ModelContextProtocol/Auth/Types/AuthorizationCodeOptions.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace ModelContextProtocol.Auth.Types; - -/// -/// Configuration options for the authorization code flow. -/// -public class AuthorizationCodeOptions -{ - /// - /// The client ID. - /// - public string ClientId { get; set; } = string.Empty; - - /// - /// The client secret. - /// - public string? ClientSecret { get; set; } - - /// - /// The redirect URI. - /// - public Uri RedirectUri { get; set; } = null!; - - /// - /// The authorization endpoint. - /// - public Uri AuthorizationEndpoint { get; set; } = null!; - - /// - /// The token endpoint. - /// - public Uri TokenEndpoint { get; set; } = null!; - - /// - /// The scope to request. - /// - public string? Scope { get; set; } - - /// - /// PKCE values for the authorization flow. - /// - public PkceValues PkceValues { get; set; } = null!; - - /// - /// A state value to protect against CSRF attacks. - /// - public string State { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/Types/AuthorizationConfig.cs b/src/ModelContextProtocol/Auth/Types/AuthorizationConfig.cs deleted file mode 100644 index 3f23573a..00000000 --- a/src/ModelContextProtocol/Auth/Types/AuthorizationConfig.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace ModelContextProtocol.Auth; - -/// -/// Configuration for OAuth authorization. -/// -public class AuthorizationConfig -{ - /// - /// The URI to redirect to after authentication. - /// - public Uri RedirectUri { get; set; } = null!; - - /// - /// The client ID to use for authentication, or null to register a new client. - /// - public string? ClientId { get; set; } - - /// - /// The client name to use for registration. - /// - public string? ClientName { get; set; } - - /// - /// The requested scopes. - /// - public IEnumerable? Scopes { get; set; } - - /// - /// The handler to invoke when authorization is required. - /// - public Func>? AuthorizationHandler { get; set; } -} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/Types/OAuthToken.cs b/src/ModelContextProtocol/Auth/Types/OAuthToken.cs deleted file mode 100644 index 0bcdb67a..00000000 --- a/src/ModelContextProtocol/Auth/Types/OAuthToken.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Auth.Types; - -/// -/// Represents an OAuth token response. -/// -public class OAuthToken -{ - /// - /// The access token issued by the authorization server. - /// - [JsonPropertyName("access_token")] - public string AccessToken { get; set; } = string.Empty; - - /// - /// The type of token issued. - /// - [JsonPropertyName("token_type")] - public string TokenType { get; set; } = string.Empty; - - /// - /// The lifetime in seconds of the access token. - /// - [JsonPropertyName("expires_in")] - public int? ExpiresIn { get; set; } - - /// - /// The refresh token used to obtain new access tokens using the same authorization grant. - /// - [JsonPropertyName("refresh_token")] - public string? RefreshToken { get; set; } - - /// - /// The scopes associated with the access token. - /// - [JsonPropertyName("scope")] - public string? Scope { get; set; } - - /// - /// An ID token as a JWT (JSON Web Token). - /// - [JsonPropertyName("id_token")] - public string? IdToken { get; set; } -} \ No newline at end of file diff --git a/src/ModelContextProtocol/Auth/Types/PkceValues.cs b/src/ModelContextProtocol/Auth/Types/PkceValues.cs deleted file mode 100644 index f413cb39..00000000 --- a/src/ModelContextProtocol/Auth/Types/PkceValues.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ModelContextProtocol.Auth; - - -/// The code verifier used to generate the code challenge. -/// The code challenge sent to the authorization server. -public record PkceValues(string CodeVerifier, string CodeChallenge); diff --git a/src/ModelContextProtocol/Utils/Base64UrlHelpers.cs b/src/ModelContextProtocol/Utils/Base64UrlHelpers.cs deleted file mode 100644 index 1ed2f23f..00000000 --- a/src/ModelContextProtocol/Utils/Base64UrlHelpers.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Text; - -namespace ModelContextProtocol.Utils -{ - /// - /// Helper methods for Base64Url encoding and decoding. - /// Based on implementation from MSAL (https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/main/src/client/Microsoft.Identity.Client/Utils/Base64UrlHelpers.cs) - /// - internal static class Base64UrlHelpers - { - private const char base64UrlCharacter62 = '-'; - private const char base64UrlCharacter63 = '_'; - - /// - /// Encoding table for base64url encoding - /// - internal static readonly char[] s_base64Table = - { - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - 'a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - '0','1','2','3','4','5','6','7','8','9', - base64UrlCharacter62, - base64UrlCharacter63 - }; - - /// - /// Converts an array of bytes to a base64url encoded string. - /// - /// An array of 8-bit unsigned integers. - /// The string representation in base64url encoding of inArray. - public static string Encode(byte[] inArray) - { - if (inArray == null) - return string.Empty; - - return Encode(inArray, 0, inArray.Length); - } - - /// - /// Converts a subset of an array of 8-bit unsigned integers to its equivalent string representation that is encoded with base-64-url digits. - /// - /// An array of 8-bit unsigned integers. - /// An offset in inArray. - /// The number of elements of inArray to convert. - /// The string representation in base64url encoding of length elements of inArray, starting at position offset. - private static string Encode(byte[] inArray, int offset, int length) - { - _ = inArray ?? throw new ArgumentNullException(nameof(inArray)); - - if (length == 0) - return string.Empty; - - if (length < 0) - throw new ArgumentOutOfRangeException(nameof(length)); - - if (offset < 0 || inArray.Length < offset) - throw new ArgumentOutOfRangeException(nameof(offset)); - - if (inArray.Length < offset + length) - throw new ArgumentOutOfRangeException(nameof(length)); - - int lengthmod3 = length % 3; - int limit = offset + (length - lengthmod3); - char[] output = new char[(length + 2) / 3 * 4]; - char[] table = s_base64Table; - int i, j = 0; - - // Process three bytes at a time, each three bytes becomes four base64 characters - for (i = offset; i < limit; i += 3) - { - byte d0 = inArray[i]; - byte d1 = inArray[i + 1]; - byte d2 = inArray[i + 2]; - - output[j + 0] = table[d0 >> 2]; - output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; - output[j + 2] = table[((d1 & 0x0f) << 2) | (d2 >> 6)]; - output[j + 3] = table[d2 & 0x3f]; - j += 4; - } - - // Handle remaining bytes and padding - i = limit; - - switch (lengthmod3) - { - case 2: - { - byte d0 = inArray[i]; - byte d1 = inArray[i + 1]; - - output[j + 0] = table[d0 >> 2]; - output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; - output[j + 2] = table[(d1 & 0x0f) << 2]; - j += 3; - } - break; - - case 1: - { - byte d0 = inArray[i]; - - output[j + 0] = table[d0 >> 2]; - output[j + 1] = table[(d0 & 0x03) << 4]; - j += 2; - } - break; - - // Default or case 0: no further operations are needed. - } - - // Return the result without creating any additional string allocations - return new string(output, 0, j); - } - - /// - /// Encodes a string using base64url encoding. - /// - /// The string to encode. - /// Base64Url encoding of the UTF8 bytes of the input string. - public static string EncodeString(string str) - { - if (str == null) - throw new ArgumentNullException(nameof(str), "Input string cannot be null."); - - return Encode(Encoding.UTF8.GetBytes(str)) ?? string.Empty; - } - } -} \ No newline at end of file diff --git a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs index 88c9048f..244f8ebd 100644 --- a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs +++ b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs @@ -128,7 +128,6 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(AuthorizationServerMetadata))] [JsonSerializable(typeof(ClientRegistrationRequest))] [JsonSerializable(typeof(ClientRegistrationResponse))] - [JsonSerializable(typeof(OAuthToken))] [JsonSerializable(typeof(ProtectedResourceMetadata))] [ExcludeFromCodeCoverage] From 003f5a0267d3dfbcba07b0f4b4c3d203984efff6 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 18:32:15 -0700 Subject: [PATCH 065/128] Standardize namespacing --- samples/ProtectedMCPClient/Program.cs | 3 --- samples/ProtectedMCPServer/Program.cs | 4 ++-- .../Auth/McpAuthenticationResponseMarker.cs | 6 ------ .../{Auth => Authentication}/McpAuthenticationDefaults.cs | 2 +- .../{Auth => Authentication}/McpAuthenticationEvents.cs | 2 +- .../{Auth => Authentication}/McpAuthenticationHandler.cs | 4 ++-- .../{Auth => Authentication}/McpAuthenticationOptions.cs | 4 ++-- .../{Auth => Authentication}/McpAuthorizationExtensions.cs | 2 +- .../McpAuthorizationPolicyExtensions.cs | 2 +- .../HttpMcpServerBuilderExtensions.cs | 1 - .../Types/AuthorizationServerMetadata.cs | 2 +- .../Types/ClientRegistrationRequest.cs | 2 +- .../Types/ClientRegistrationResponse.cs | 2 +- .../Types/ProtectedResourceMetadata.cs | 2 +- src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs | 3 +-- 15 files changed, 15 insertions(+), 26 deletions(-) delete mode 100644 src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMarker.cs rename src/ModelContextProtocol.AspNetCore/{Auth => Authentication}/McpAuthenticationDefaults.cs (88%) rename src/ModelContextProtocol.AspNetCore/{Auth => Authentication}/McpAuthenticationEvents.cs (73%) rename src/ModelContextProtocol.AspNetCore/{Auth => Authentication}/McpAuthenticationHandler.cs (98%) rename src/ModelContextProtocol.AspNetCore/{Auth => Authentication}/McpAuthenticationOptions.cs (95%) rename src/ModelContextProtocol.AspNetCore/{Auth => Authentication}/McpAuthorizationExtensions.cs (97%) rename src/ModelContextProtocol.AspNetCore/{Auth => Authentication}/McpAuthorizationPolicyExtensions.cs (98%) rename src/ModelContextProtocol/{Auth => Authentication}/Types/AuthorizationServerMetadata.cs (98%) rename src/ModelContextProtocol/{Auth => Authentication}/Types/ClientRegistrationRequest.cs (98%) rename src/ModelContextProtocol/{Auth => Authentication}/Types/ClientRegistrationResponse.cs (94%) rename src/ModelContextProtocol/{Auth => Authentication}/Types/ProtectedResourceMetadata.cs (95%) diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 5e554a55..2a7453e2 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -1,6 +1,3 @@ -using System.Net.Http; -using System.Threading.Tasks; -using ModelContextProtocol.Auth; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol.Transport; diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 4e0be3e1..740b0a7a 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; -using ModelContextProtocol.AspNetCore.Auth; -using ModelContextProtocol.Auth.Types; +using ModelContextProtocol.AspNetCore.Authentication; +using ModelContextProtocol.Types.Authentication; using ProtectedMCPServer.Tools; using System.Net.Http.Headers; using System.Security.Claims; diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMarker.cs b/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMarker.cs deleted file mode 100644 index a5753e93..00000000 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationResponseMarker.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ModelContextProtocol.AspNetCore.Auth; - -/// -/// Marker class to indicate that MCP authentication response middleware should be used. -/// -internal class McpAuthenticationResponseMarker { } diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationDefaults.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationDefaults.cs similarity index 88% rename from src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationDefaults.cs rename to src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationDefaults.cs index 5b5437bb..4c720c65 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationDefaults.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationDefaults.cs @@ -1,4 +1,4 @@ -namespace ModelContextProtocol.AspNetCore.Auth; +namespace ModelContextProtocol.AspNetCore.Authentication; /// /// Default values used by MCP authentication. diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationEvents.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationEvents.cs similarity index 73% rename from src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationEvents.cs rename to src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationEvents.cs index b1d59915..7a7eb29b 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationEvents.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationEvents.cs @@ -1,4 +1,4 @@ -namespace ModelContextProtocol.AspNetCore.Auth +namespace ModelContextProtocol.AspNetCore.Authentication { /// /// Represents the authentication events for Model Context Protocol. diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs similarity index 98% rename from src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs rename to src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index 5081aada..918a9d11 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -2,11 +2,11 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using ModelContextProtocol.Auth.Types; +using ModelContextProtocol.Types.Authentication; using ModelContextProtocol.Utils.Json; using System.Text.Encodings.Web; -namespace ModelContextProtocol.AspNetCore.Auth; +namespace ModelContextProtocol.AspNetCore.Authentication; /// /// Authentication handler for MCP protocol that adds resource metadata to challenge responses diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs similarity index 95% rename from src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs rename to src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs index 23fc9b48..7e174959 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthenticationOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; -using ModelContextProtocol.Auth.Types; +using ModelContextProtocol.Types.Authentication; -namespace ModelContextProtocol.AspNetCore.Auth; +namespace ModelContextProtocol.AspNetCore.Authentication; /// /// Options for the MCP authentication handler. diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationExtensions.cs similarity index 97% rename from src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs rename to src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationExtensions.cs index 7441b295..96f200da 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationExtensions.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.Authentication; -using ModelContextProtocol.AspNetCore.Auth; +using ModelContextProtocol.AspNetCore.Authentication; namespace Microsoft.Extensions.DependencyInjection; diff --git a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationPolicyExtensions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs similarity index 98% rename from src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationPolicyExtensions.cs rename to src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs index 4d3991fb..639b1ef3 100644 --- a/src/ModelContextProtocol.AspNetCore/Auth/McpAuthorizationPolicyExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.Authorization; -using ModelContextProtocol.AspNetCore.Auth; +using ModelContextProtocol.AspNetCore.Authentication; namespace Microsoft.Extensions.DependencyInjection; diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 39ba45e4..8bff4596 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using ModelContextProtocol.AspNetCore; -using ModelContextProtocol.AspNetCore.Auth; using ModelContextProtocol.Server; namespace Microsoft.Extensions.DependencyInjection; diff --git a/src/ModelContextProtocol/Auth/Types/AuthorizationServerMetadata.cs b/src/ModelContextProtocol/Authentication/Types/AuthorizationServerMetadata.cs similarity index 98% rename from src/ModelContextProtocol/Auth/Types/AuthorizationServerMetadata.cs rename to src/ModelContextProtocol/Authentication/Types/AuthorizationServerMetadata.cs index ff204688..57f886bf 100644 --- a/src/ModelContextProtocol/Auth/Types/AuthorizationServerMetadata.cs +++ b/src/ModelContextProtocol/Authentication/Types/AuthorizationServerMetadata.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ModelContextProtocol.Auth; +namespace ModelContextProtocol.Types.Authentication; /// /// Represents the metadata about an OAuth authorization server. diff --git a/src/ModelContextProtocol/Auth/Types/ClientRegistrationRequest.cs b/src/ModelContextProtocol/Authentication/Types/ClientRegistrationRequest.cs similarity index 98% rename from src/ModelContextProtocol/Auth/Types/ClientRegistrationRequest.cs rename to src/ModelContextProtocol/Authentication/Types/ClientRegistrationRequest.cs index 98d34444..a3786d40 100644 --- a/src/ModelContextProtocol/Auth/Types/ClientRegistrationRequest.cs +++ b/src/ModelContextProtocol/Authentication/Types/ClientRegistrationRequest.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ModelContextProtocol.Auth.Types; +namespace ModelContextProtocol.Types.Authentication; /// /// Represents the client registration request metadata. diff --git a/src/ModelContextProtocol/Auth/Types/ClientRegistrationResponse.cs b/src/ModelContextProtocol/Authentication/Types/ClientRegistrationResponse.cs similarity index 94% rename from src/ModelContextProtocol/Auth/Types/ClientRegistrationResponse.cs rename to src/ModelContextProtocol/Authentication/Types/ClientRegistrationResponse.cs index dba40c3a..2ddd9ed2 100644 --- a/src/ModelContextProtocol/Auth/Types/ClientRegistrationResponse.cs +++ b/src/ModelContextProtocol/Authentication/Types/ClientRegistrationResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ModelContextProtocol.Auth; +namespace ModelContextProtocol.Types.Authentication; /// /// Represents the client registration response. diff --git a/src/ModelContextProtocol/Auth/Types/ProtectedResourceMetadata.cs b/src/ModelContextProtocol/Authentication/Types/ProtectedResourceMetadata.cs similarity index 95% rename from src/ModelContextProtocol/Auth/Types/ProtectedResourceMetadata.cs rename to src/ModelContextProtocol/Authentication/Types/ProtectedResourceMetadata.cs index 2a1ba160..aef75cc5 100644 --- a/src/ModelContextProtocol/Auth/Types/ProtectedResourceMetadata.cs +++ b/src/ModelContextProtocol/Authentication/Types/ProtectedResourceMetadata.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ModelContextProtocol.Auth.Types; +namespace ModelContextProtocol.Types.Authentication; /// /// Represents the resource metadata for OAuth authorization. diff --git a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs index 244f8ebd..14b98853 100644 --- a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs +++ b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.AI; -using ModelContextProtocol.Auth; -using ModelContextProtocol.Auth.Types; +using ModelContextProtocol.Types.Authentication; using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Protocol.Types; using System.Diagnostics.CodeAnalysis; From ceee919e13dcfa4612972063e80e1b2231349def Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 21:49:46 -0700 Subject: [PATCH 066/128] Updating the approach for client auth --- samples/ProtectedMCPClient/Program.cs | 58 ++-- .../SimpleAccessTokenProvider.cs | 84 ++++++ .../AuthenticationDelegatingHandler.cs | 119 ++++++++ .../Authentication/AuthenticationUtils.cs | 275 ++++++++++++++++++ .../Authentication/HttpClientExtensions.cs | 67 +++++ .../Authentication/IAccessTokenProvider.cs | 24 ++ .../IClientRegistrationProvider.cs | 22 ++ 7 files changed, 625 insertions(+), 24 deletions(-) create mode 100644 samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs create mode 100644 src/ModelContextProtocol/Authentication/AuthenticationDelegatingHandler.cs create mode 100644 src/ModelContextProtocol/Authentication/AuthenticationUtils.cs create mode 100644 src/ModelContextProtocol/Authentication/HttpClientExtensions.cs create mode 100644 src/ModelContextProtocol/Authentication/IAccessTokenProvider.cs create mode 100644 src/ModelContextProtocol/Authentication/IClientRegistrationProvider.cs diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 2a7453e2..de4f29f8 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -1,5 +1,7 @@ +using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol.Transport; +using System.Diagnostics; namespace ProtectedMCPClient; @@ -7,32 +9,22 @@ class Program { static async Task Main(string[] args) { - Console.WriteLine("MCP Secure Weather Client with OAuth Authentication"); - Console.WriteLine("=================================================="); + Console.WriteLine("MCP Secure Weather Client with Authentication"); + Console.WriteLine("=============================================="); Console.WriteLine(); - //// Create the authorization config with HTTP listener - //var authConfig = new AuthorizationConfig - //{ - // ClientId = "6ad97b5f-7a7b-413f-8603-7a3517d4adb8", - // Scopes = ["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"] - //}.UseHttpListener(hostname: "localhost", listenPort: 1170); - - //// Create an HTTP client with OAuth handling - //var oauthHandler = new OAuthDelegatingHandler( - // redirectUri: authConfig.RedirectUri, - // clientId: authConfig.ClientId, - // clientName: authConfig.ClientName, - // scopes: authConfig.Scopes, - // authorizationHandler: authConfig.AuthorizationHandler) - //{ - // // The OAuth handler needs an inner handler - // InnerHandler = new HttpClientHandler() - //}; - - var httpClient = new HttpClient(); + // Create a standard HttpClient with authentication configured var serverUrl = "http://localhost:7071/sse"; // Default server URL + // Ask for the API key + Console.WriteLine("Enter your API key (or press Enter to use default):"); + var apiKey = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(apiKey)) + { + apiKey = "demo-api-key-12345"; // Default API key for demonstration + Console.WriteLine($"Using default API key: {apiKey}"); + } + // Allow the user to specify a different server URL Console.WriteLine($"Server URL (press Enter for default: {serverUrl}):"); var userInput = Console.ReadLine(); @@ -41,10 +33,14 @@ static async Task Main(string[] args) serverUrl = userInput; } + // Create a single HttpClient with authentication configured + var tokenProvider = new SimpleAccessTokenProvider(apiKey, new Uri(serverUrl)); + var httpClient = new HttpClient().UseAuthenticationProvider(tokenProvider); + Console.WriteLine(); Console.WriteLine($"Connecting to weather server at {serverUrl}..."); - Console.WriteLine("When prompted for authorization, a browser window will open automatically."); - Console.WriteLine("Complete the authentication in the browser, and this application will continue automatically."); + Console.WriteLine("When prompted for authorization, the challenge will be verified automatically."); + Console.WriteLine("If required, you'll be guided through any necessary authentication steps."); Console.WriteLine(); try @@ -82,6 +78,15 @@ static async Task Main(string[] args) Console.WriteLine(); } } + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + // Handle authentication failures specifically + Console.WriteLine("Authentication failed. The server returned a 401 Unauthorized response."); + Console.WriteLine($"Details: {ex.Message}"); + + // Additional handling for 401 - could add manual authentication retry here + Console.WriteLine("You might need to provide a different API key or authentication credentials."); + } catch (Exception ex) { Console.WriteLine($"Error: {ex.Message}"); @@ -89,6 +94,11 @@ static async Task Main(string[] args) { Console.WriteLine($"Inner error: {ex.InnerException.Message}"); } + + // Print stack trace in debug builds + #if DEBUG + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + #endif } Console.WriteLine("Press any key to exit..."); diff --git a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs new file mode 100644 index 00000000..07307642 --- /dev/null +++ b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs @@ -0,0 +1,84 @@ +using ModelContextProtocol.Authentication; +using System.Collections.Concurrent; + +namespace ProtectedMCPClient; + +/// +/// A simple implementation of IAccessTokenProvider that uses a fixed API key. +/// This is just for demonstration purposes. +/// +public class SimpleAccessTokenProvider : IAccessTokenProvider +{ + private readonly string _apiKey; + private readonly ConcurrentDictionary _tokenCache = new(); + private readonly Uri _serverUrl; + + public SimpleAccessTokenProvider(string apiKey, Uri serverUrl) + { + _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); + _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); + } + + /// + public Task GetAuthenticationTokenAsync(Uri resourceUri, CancellationToken cancellationToken = default) + { + // In a real implementation, you might use different tokens for different resources, + // or refresh tokens when they're about to expire + return Task.FromResult(_apiKey); + } + + /// + public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) + { + try + { + // Use the updated AuthenticationChallengeHandler to handle the 401 challenge + var resourceMetadata = await AuthenticationUtils.HandleAuthenticationChallengeAsync( + response, + _serverUrl, + cancellationToken); + + // If we get here, the resource metadata is valid and matches our server + Console.WriteLine($"Successfully validated resource metadata for: {resourceMetadata.Resource}"); + + // For a real implementation, you would: + // 1. Use the metadata to get information about the authorization servers + // 2. Obtain a new token from one of those authorization servers + // 3. Store the new token for future requests + + // Example of what a real implementation might do: + /* + if (resourceMetadata.AuthorizationServers?.Count > 0) + { + var authServerUrl = resourceMetadata.AuthorizationServers[0]; + var authServerMetadata = await AuthenticationUtils.FetchAuthorizationServerMetadataAsync( + authServerUrl, cancellationToken); + + if (authServerMetadata != null) + { + // Use auth server metadata to obtain a new token + // Store the token in _tokenCache + // Return true to indicate the unauthorized response was handled + return true; + } + } + */ + + // For now, we still return false since we're not actually refreshing the token + Console.WriteLine("API key is valid, but might not have sufficient permissions."); + return false; + } + catch (InvalidOperationException ex) + { + // Log the specific error about why the challenge handling failed + Console.WriteLine($"Authentication challenge failed: {ex.Message}"); + return false; + } + catch (Exception ex) + { + // Log any unexpected errors + Console.WriteLine($"Unexpected error during authentication challenge: {ex.Message}"); + return false; + } + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Authentication/AuthenticationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthenticationDelegatingHandler.cs new file mode 100644 index 00000000..d2978c1c --- /dev/null +++ b/src/ModelContextProtocol/Authentication/AuthenticationDelegatingHandler.cs @@ -0,0 +1,119 @@ +using System.Net.Http; +using System.Net.Http.Headers; + +namespace ModelContextProtocol.Authentication; + +/// +/// A delegating handler that adds authentication tokens to requests and handles 401 responses. +/// +internal class AuthenticationDelegatingHandler : DelegatingHandler +{ + private readonly IAccessTokenProvider _tokenProvider; + private readonly string _scheme; + + /// + /// Initializes a new instance of the class. + /// + /// The provider that supplies authentication tokens. + /// The authentication scheme to use, e.g., "Bearer". + public AuthenticationDelegatingHandler(IAccessTokenProvider tokenProvider, string scheme) + { + _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider)); + _scheme = scheme ?? throw new ArgumentNullException(nameof(scheme)); + } + + /// + /// Sends an HTTP request with authentication handling. + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Add the authentication token to the request if not already present + if (request.Headers.Authorization == null) + { + await AddAuthenticationHeaderAsync(request, cancellationToken); + } + + // Send the request through the inner handler + var response = await base.SendAsync(request, cancellationToken); + + // Handle unauthorized responses + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + // Try to handle the unauthorized response + var handled = await _tokenProvider.HandleUnauthorizedResponseAsync( + response, + cancellationToken); + + if (handled) + { + // If the unauthorized response was handled, retry the request + var retryRequest = await CloneHttpRequestMessageAsync(request); + + // Get a new token + await AddAuthenticationHeaderAsync(retryRequest, cancellationToken); + + // Send the retry request + return await base.SendAsync(retryRequest, cancellationToken); + } + } + + return response; + } + + /// + /// Adds an authorization header to the request. + /// + private async Task AddAuthenticationHeaderAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri != null) + { + var token = await _tokenProvider.GetAuthenticationTokenAsync(request.RequestUri, cancellationToken); + if (!string.IsNullOrEmpty(token)) + { + request.Headers.Authorization = new AuthenticationHeaderValue(_scheme, token); + } + } + } + + /// + /// Creates a clone of the HTTP request message. + /// + private static async Task CloneHttpRequestMessageAsync(HttpRequestMessage request) + { + var clone = new HttpRequestMessage(request.Method, request.RequestUri); + + // Copy the request headers + foreach (var header in request.Headers) + { + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + // Copy the request content if present + if (request.Content != null) + { + var contentBytes = await request.Content.ReadAsByteArrayAsync(); + var cloneContent = new ByteArrayContent(contentBytes); + + // Copy the content headers + foreach (var header in request.Content.Headers) + { + cloneContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + clone.Content = cloneContent; + } + + // Copy the request properties +#pragma warning disable CS0618 // Type or member is obsolete + foreach (var property in request.Properties) + { + clone.Properties.Add(property); + } +#pragma warning restore CS0618 // Type or member is obsolete + + // Copy the request version + clone.Version = request.Version; + + return clone; + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Authentication/AuthenticationUtils.cs b/src/ModelContextProtocol/Authentication/AuthenticationUtils.cs new file mode 100644 index 00000000..b613a89c --- /dev/null +++ b/src/ModelContextProtocol/Authentication/AuthenticationUtils.cs @@ -0,0 +1,275 @@ +using ModelContextProtocol.Types.Authentication; +using ModelContextProtocol.Utils.Json; +using System.Text.Json; + +namespace ModelContextProtocol.Authentication; + +/// +/// Provides utility methods for handling authentication in MCP clients. +/// +public static class AuthenticationUtils +{ + /// + /// Extracts protected resource metadata from an unauthorized response. + /// + /// The HTTP response containing the WWW-Authenticate header. + /// The extracted ProtectedResourceMetadata, or null if it couldn't be extracted. + public static ProtectedResourceMetadata? ExtractProtectedResourceMetadata(HttpResponseMessage response) + { + if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized) + { + return null; + } + + // Extract the WWW-Authenticate header + if (!response.Headers.WwwAuthenticate.Any()) + { + return null; + } + + // Look for the Bearer authentication scheme with resource_metadata parameter + foreach (var header in response.Headers.WwwAuthenticate) + { + if (header.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase)) + { + var parameters = header.Parameter; + if (string.IsNullOrEmpty(parameters)) + { + continue; + } + + // Parse the parameters to find resource_metadata + var resourceMetadataUrl = ParseWwwAuthenticateParameters(parameters, "resource_metadata"); + if (resourceMetadataUrl != null) + { + return FetchProtectedResourceMetadataAsync(new Uri(resourceMetadataUrl)).GetAwaiter().GetResult(); + } + } + } + + return null; + } + + /// + /// Fetches the protected resource metadata from the provided URL. + /// + /// The URL to fetch the metadata from. + /// A token to cancel the operation. + /// The fetched ProtectedResourceMetadata, or null if it couldn't be fetched. + public static async Task FetchProtectedResourceMetadataAsync( + Uri metadataUrl, + CancellationToken cancellationToken = default) + { + using var httpClient = new HttpClient(); + try + { + var response = await httpClient.GetAsync(metadataUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStreamAsync(); + return await JsonSerializer.DeserializeAsync(content, + McpJsonUtilities.JsonContext.Default.ProtectedResourceMetadata, + cancellationToken); + } + catch (Exception) + { + return null; + } + } + + /// + /// Fetches the authorization server metadata from a server URL, trying both well-known endpoints. + /// + /// The base URL of the authorization server. + /// A token to cancel the operation. + /// The fetched AuthorizationServerMetadata, or null if it couldn't be fetched. + public static async Task FetchAuthorizationServerMetadataAsync( + Uri authorizationServerUrl, + CancellationToken cancellationToken = default) + { + using var httpClient = new HttpClient(); + + // Try OpenID Connect configuration endpoint first, then OAuth Authorization Server Metadata endpoint + string[] wellKnownEndpoints = { + "/.well-known/openid-configuration", + "/.well-known/oauth-authorization-server" + }; + + foreach (var endpoint in wellKnownEndpoints) + { + var metadataUrl = new Uri(authorizationServerUrl, endpoint); + var metadata = await TryFetchMetadataAsync(httpClient, metadataUrl, cancellationToken); + if (metadata != null) + { + return metadata; + } + } + + return null; + } + + /// + /// Attempts to fetch metadata from a specific URL. + /// + /// The HTTP client to use for the request. + /// The URL to fetch metadata from. + /// A token to cancel the operation. + /// The metadata if successful, or null if the fetch fails. + private static async Task TryFetchMetadataAsync( + HttpClient httpClient, + Uri metadataUrl, + CancellationToken cancellationToken) + { + try + { + var response = await httpClient.GetAsync(metadataUrl, cancellationToken); + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStreamAsync(); + return await JsonSerializer.DeserializeAsync(content, + McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata, + cancellationToken); + } + } + catch (Exception) + { + // Ignore exceptions and return null + } + + return null; + } + + /// + /// Verifies that the resource URI in the metadata matches the server URL. + /// + /// The metadata to verify. + /// The server URL to compare against. + /// True if the resource URI matches the server, otherwise false. + public static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri serverUrl) + { + if (protectedResourceMetadata.Resource == null || serverUrl == null) + { + return false; + } + + // Compare hosts using Uri properties directly + return Uri.Compare( + protectedResourceMetadata.Resource, + serverUrl, + UriComponents.Host, + UriFormat.UriEscaped, + StringComparison.OrdinalIgnoreCase) == 0; + } + + /// + /// Responds to a 401 challenge by parsing the WWW-Authenticate header, fetching the resource metadata, + /// verifying the resource match, and returning the metadata if valid. + /// + /// The HTTP response containing the WWW-Authenticate header. + /// The server URL to verify against the resource metadata. + /// A token to cancel the operation. + /// The resource metadata if the resource matches the server, otherwise throws an exception. + /// Thrown when the response is not a 401, lacks a WWW-Authenticate header, + /// lacks a resource_metadata parameter, the metadata can't be fetched, or the resource URI doesn't match the server URL. + public static async Task HandleAuthenticationChallengeAsync( + HttpResponseMessage response, + Uri serverUrl, + CancellationToken cancellationToken = default) + { + if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized) + { + throw new InvalidOperationException($"Expected a 401 Unauthorized response, but received {(int)response.StatusCode} {response.StatusCode}"); + } + + // Extract the WWW-Authenticate header + if (!response.Headers.WwwAuthenticate.Any()) + { + throw new InvalidOperationException("The 401 response does not contain a WWW-Authenticate header"); + } + + // Look for the Bearer authentication scheme with resource_metadata parameter + string? resourceMetadataUrl = null; + foreach (var header in response.Headers.WwwAuthenticate) + { + if (header.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase)) + { + var parameters = header.Parameter; + if (string.IsNullOrEmpty(parameters)) + { + continue; + } + + // Parse the parameters to find resource_metadata + resourceMetadataUrl = ParseWwwAuthenticateParameters(parameters, "resource_metadata"); + if (resourceMetadataUrl != null) + { + break; + } + } + } + + if (resourceMetadataUrl == null) + { + throw new InvalidOperationException("The WWW-Authenticate header does not contain a resource_metadata parameter"); + } + + Uri metadataUri = new Uri(resourceMetadataUrl); + + // Fetch the resource metadata + var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken); + if (metadata == null) + { + throw new InvalidOperationException($"Failed to fetch resource metadata from {resourceMetadataUrl}"); + } + + // Verify the resource matches the server + if (!VerifyResourceMatch(metadata, serverUrl)) + { + throw new InvalidOperationException( + $"Resource URI in metadata ({metadata.Resource}) does not match the server URI ({serverUrl})"); + } + + return metadata; + } + + /// + /// Parses the WWW-Authenticate header parameters to extract a specific parameter. + /// + /// The parameter string from the WWW-Authenticate header. + /// The name of the parameter to extract. + /// The value of the parameter, or null if not found. + public static string? ParseWwwAuthenticateParameters(string parameters, string parameterName) + { + // Handle parameters in the format: param1="value1", param2="value2" + var paramDict = parameters.Split(',') + .Select(p => p.Trim()) + .Select(p => + { + var parts = p.Split(new[] { '=' }, 2); + if (parts.Length != 2) + { + return new KeyValuePair(string.Empty, string.Empty); + } + + var key = parts[0].Trim(); + var value = parts[1].Trim(); + + // Remove surrounding quotes if present + if (value.StartsWith("\"") && value.EndsWith("\"")) + { + value = value.Substring(1, value.Length - 2); + } + + return new KeyValuePair(key, value); + }) + .Where(kvp => !string.IsNullOrEmpty(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + if (paramDict.TryGetValue(parameterName, out var value)) + { + return value; + } + + return null; + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs b/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs new file mode 100644 index 00000000..129bf921 --- /dev/null +++ b/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs @@ -0,0 +1,67 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Extension methods for related to authentication. +/// +public static class HttpClientExtensions +{ + /// + /// Configures the to use the specified access token provider for authentication. + /// + /// The HTTP client to configure. + /// The token provider that will supply authentication tokens. + /// The authentication scheme to use. Defaults to "Bearer". + /// A new that automatically handles authentication. + /// + /// This extension method configures the HttpClient with a handler that automatically: + /// + /// Adds authentication tokens to outgoing requests + /// Handles 401 Unauthorized responses by attempting to refresh tokens + /// Retries the request with the new token if token refresh is successful + /// + /// + public static HttpClient UseAuthenticationProvider(this HttpClient httpClient, IAccessTokenProvider tokenProvider, string scheme = "Bearer") + { + if (httpClient == null) + throw new ArgumentNullException(nameof(httpClient)); + + if (tokenProvider == null) + throw new ArgumentNullException(nameof(tokenProvider)); + + if (string.IsNullOrWhiteSpace(scheme)) + throw new ArgumentException("Authentication scheme cannot be null or whitespace", nameof(scheme)); + + // Create a new HttpClientHandler with the same settings as the current client + var handler = new HttpClientHandler(); + if (httpClient.DefaultRequestHeaders != null && httpClient.DefaultRequestHeaders.Host != null) + { + // Copy relevant settings from the original client's handler if possible + // This is a simplified approach - some settings might not be accessible + } + + // Create our authentication delegating handler with the token provider + var authHandler = new AuthenticationDelegatingHandler(tokenProvider, scheme) + { + InnerHandler = handler + }; + + // Create a new HttpClient with our delegating handler + var newClient = new HttpClient(authHandler); + + // Copy settings from the original client + newClient.BaseAddress = httpClient.BaseAddress; + newClient.Timeout = httpClient.Timeout; + newClient.MaxResponseContentBufferSize = httpClient.MaxResponseContentBufferSize; + + // Copy headers from original client to new client + if (httpClient.DefaultRequestHeaders != null) + { + foreach (var header in httpClient.DefaultRequestHeaders) + { + newClient.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); + } + } + + return newClient; + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Authentication/IAccessTokenProvider.cs b/src/ModelContextProtocol/Authentication/IAccessTokenProvider.cs new file mode 100644 index 00000000..ceda55a7 --- /dev/null +++ b/src/ModelContextProtocol/Authentication/IAccessTokenProvider.cs @@ -0,0 +1,24 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Defines an interface for providing authentication for requests. +/// This is the main extensibility point for authentication in MCP clients. +/// +public interface IAccessTokenProvider +{ + /// + /// Gets an authentication token or credential for authenticating requests to a resource. + /// + /// The URI of the resource requiring authentication. + /// A token to cancel the operation. + /// An authentication token string or null if no token could be obtained. + Task GetAuthenticationTokenAsync(Uri resourceUri, CancellationToken cancellationToken = default); + + /// + /// Handles a 401 Unauthorized response from a resource. + /// + /// The HTTP response that contained the 401 status code. + /// A token to cancel the operation. + /// True if the provider was able to handle the unauthorized response, otherwise false. + Task HandleUnauthorizedResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Authentication/IClientRegistrationProvider.cs b/src/ModelContextProtocol/Authentication/IClientRegistrationProvider.cs new file mode 100644 index 00000000..9dbc5ccc --- /dev/null +++ b/src/ModelContextProtocol/Authentication/IClientRegistrationProvider.cs @@ -0,0 +1,22 @@ +using ModelContextProtocol.Types.Authentication; + +namespace ModelContextProtocol.Authentication; + +/// +/// Defines an interface for registering OAuth clients with authorization servers. +/// This is an extensibility point for client registration in MCP clients. +/// +public interface IClientRegistrationProvider +{ + /// + /// Registers a client with an OAuth authorization server. + /// + /// Metadata about the authorization server. + /// The client registration request data. + /// A token to cancel the operation. + /// The client registration response containing client credentials. + Task RegisterClientAsync( + AuthorizationServerMetadata authorizationServerMetadata, + ClientRegistrationRequest registrationRequest, + CancellationToken cancellationToken = default); +} \ No newline at end of file From 51bb38b2fa40fe03d94d76b9305db98e6bc1f1fe Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 21:54:56 -0700 Subject: [PATCH 067/128] Cleanup for client logic --- .../SimpleAccessTokenProvider.cs | 2 +- .../Authentication/AuthenticationUtils.cs | 51 ++----------------- 2 files changed, 6 insertions(+), 47 deletions(-) diff --git a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs index 07307642..51d16c74 100644 --- a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs +++ b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs @@ -33,7 +33,7 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp try { // Use the updated AuthenticationChallengeHandler to handle the 401 challenge - var resourceMetadata = await AuthenticationUtils.HandleAuthenticationChallengeAsync( + var resourceMetadata = await AuthenticationUtils.ExtractProtectedResourceMetadata( response, _serverUrl, cancellationToken); diff --git a/src/ModelContextProtocol/Authentication/AuthenticationUtils.cs b/src/ModelContextProtocol/Authentication/AuthenticationUtils.cs index b613a89c..bedc76ff 100644 --- a/src/ModelContextProtocol/Authentication/AuthenticationUtils.cs +++ b/src/ModelContextProtocol/Authentication/AuthenticationUtils.cs @@ -9,54 +9,13 @@ namespace ModelContextProtocol.Authentication; /// public static class AuthenticationUtils { - /// - /// Extracts protected resource metadata from an unauthorized response. - /// - /// The HTTP response containing the WWW-Authenticate header. - /// The extracted ProtectedResourceMetadata, or null if it couldn't be extracted. - public static ProtectedResourceMetadata? ExtractProtectedResourceMetadata(HttpResponseMessage response) - { - if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized) - { - return null; - } - - // Extract the WWW-Authenticate header - if (!response.Headers.WwwAuthenticate.Any()) - { - return null; - } - - // Look for the Bearer authentication scheme with resource_metadata parameter - foreach (var header in response.Headers.WwwAuthenticate) - { - if (header.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase)) - { - var parameters = header.Parameter; - if (string.IsNullOrEmpty(parameters)) - { - continue; - } - - // Parse the parameters to find resource_metadata - var resourceMetadataUrl = ParseWwwAuthenticateParameters(parameters, "resource_metadata"); - if (resourceMetadataUrl != null) - { - return FetchProtectedResourceMetadataAsync(new Uri(resourceMetadataUrl)).GetAwaiter().GetResult(); - } - } - } - - return null; - } - /// /// Fetches the protected resource metadata from the provided URL. /// /// The URL to fetch the metadata from. /// A token to cancel the operation. /// The fetched ProtectedResourceMetadata, or null if it couldn't be fetched. - public static async Task FetchProtectedResourceMetadataAsync( + private static async Task FetchProtectedResourceMetadataAsync( Uri metadataUrl, CancellationToken cancellationToken = default) { @@ -145,7 +104,7 @@ public static class AuthenticationUtils /// The metadata to verify. /// The server URL to compare against. /// True if the resource URI matches the server, otherwise false. - public static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri serverUrl) + private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri serverUrl) { if (protectedResourceMetadata.Resource == null || serverUrl == null) { @@ -171,7 +130,7 @@ public static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResour /// The resource metadata if the resource matches the server, otherwise throws an exception. /// Thrown when the response is not a 401, lacks a WWW-Authenticate header, /// lacks a resource_metadata parameter, the metadata can't be fetched, or the resource URI doesn't match the server URL. - public static async Task HandleAuthenticationChallengeAsync( + public static async Task ExtractProtectedResourceMetadata( HttpResponseMessage response, Uri serverUrl, CancellationToken cancellationToken = default) @@ -238,14 +197,14 @@ public static async Task HandleAuthenticationChalleng /// The parameter string from the WWW-Authenticate header. /// The name of the parameter to extract. /// The value of the parameter, or null if not found. - public static string? ParseWwwAuthenticateParameters(string parameters, string parameterName) + private static string? ParseWwwAuthenticateParameters(string parameters, string parameterName) { // Handle parameters in the format: param1="value1", param2="value2" var paramDict = parameters.Split(',') .Select(p => p.Trim()) .Select(p => { - var parts = p.Split(new[] { '=' }, 2); + var parts = p.Split(['='], 2); if (parts.Length != 2) { return new KeyValuePair(string.Empty, string.Empty); From afd05af56e74a01ebe8ca591bbb27f6b90fbc6fc Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 21:58:56 -0700 Subject: [PATCH 068/128] Cleanup --- samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs | 1 + .../Authentication/AuthenticationDelegatingHandler.cs | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs index 51d16c74..9666fc55 100644 --- a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs +++ b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs @@ -22,6 +22,7 @@ public SimpleAccessTokenProvider(string apiKey, Uri serverUrl) /// public Task GetAuthenticationTokenAsync(Uri resourceUri, CancellationToken cancellationToken = default) { + Console.WriteLine("Tried to get a token!"); // In a real implementation, you might use different tokens for different resources, // or refresh tokens when they're about to expire return Task.FromResult(_apiKey); diff --git a/src/ModelContextProtocol/Authentication/AuthenticationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthenticationDelegatingHandler.cs index d2978c1c..26f63fca 100644 --- a/src/ModelContextProtocol/Authentication/AuthenticationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthenticationDelegatingHandler.cs @@ -1,4 +1,3 @@ -using System.Net.Http; using System.Net.Http.Headers; namespace ModelContextProtocol.Authentication; From a0fbec662558ed8c2ef9002d2509b9fc10c6b1ce Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sat, 3 May 2025 22:43:12 -0700 Subject: [PATCH 069/128] Simplify client logic --- samples/ProtectedMCPClient/Program.cs | 11 +- .../SimpleAccessTokenProvider.cs | 229 ++++++++++++++++-- samples/ProtectedMCPClient/TokenContainer.cs | 12 + .../IClientRegistrationProvider.cs | 22 -- .../Types/ClientRegistrationRequest.cs | 99 -------- .../Types/ClientRegistrationResponse.cs | 33 --- .../Utils/Json/McpJsonUtilities.cs | 2 - 7 files changed, 218 insertions(+), 190 deletions(-) create mode 100644 samples/ProtectedMCPClient/TokenContainer.cs delete mode 100644 src/ModelContextProtocol/Authentication/IClientRegistrationProvider.cs delete mode 100644 src/ModelContextProtocol/Authentication/Types/ClientRegistrationRequest.cs delete mode 100644 src/ModelContextProtocol/Authentication/Types/ClientRegistrationResponse.cs diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index de4f29f8..8a8688b7 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -16,15 +16,6 @@ static async Task Main(string[] args) // Create a standard HttpClient with authentication configured var serverUrl = "http://localhost:7071/sse"; // Default server URL - // Ask for the API key - Console.WriteLine("Enter your API key (or press Enter to use default):"); - var apiKey = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(apiKey)) - { - apiKey = "demo-api-key-12345"; // Default API key for demonstration - Console.WriteLine($"Using default API key: {apiKey}"); - } - // Allow the user to specify a different server URL Console.WriteLine($"Server URL (press Enter for default: {serverUrl}):"); var userInput = Console.ReadLine(); @@ -34,7 +25,7 @@ static async Task Main(string[] args) } // Create a single HttpClient with authentication configured - var tokenProvider = new SimpleAccessTokenProvider(apiKey, new Uri(serverUrl)); + var tokenProvider = new SimpleAccessTokenProvider(new Uri(serverUrl)); var httpClient = new HttpClient().UseAuthenticationProvider(tokenProvider); Console.WriteLine(); diff --git a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs index 9666fc55..5a5523c5 100644 --- a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs +++ b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs @@ -1,5 +1,11 @@ using ModelContextProtocol.Authentication; +using ModelContextProtocol.Types.Authentication; using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Web; namespace ProtectedMCPClient; @@ -7,25 +13,36 @@ namespace ProtectedMCPClient; /// A simple implementation of IAccessTokenProvider that uses a fixed API key. /// This is just for demonstration purposes. /// -public class SimpleAccessTokenProvider : IAccessTokenProvider +public partial class SimpleAccessTokenProvider : IAccessTokenProvider { - private readonly string _apiKey; - private readonly ConcurrentDictionary _tokenCache = new(); + private readonly ConcurrentDictionary _tokenCache = new(); private readonly Uri _serverUrl; + private readonly string _clientId; + private readonly string _clientSecret; + private readonly Uri _redirectUri; - public SimpleAccessTokenProvider(string apiKey, Uri serverUrl) + public SimpleAccessTokenProvider(Uri serverUrl, string clientId = "demo-client", string clientSecret = "", Uri? redirectUri = null) { - _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); + _clientId = clientId; + _clientSecret = clientSecret; + _redirectUri = redirectUri ?? new Uri("http://localhost:8080/callback"); } /// - public Task GetAuthenticationTokenAsync(Uri resourceUri, CancellationToken cancellationToken = default) + public async Task GetAuthenticationTokenAsync(Uri resourceUri, CancellationToken cancellationToken = default) { - Console.WriteLine("Tried to get a token!"); - // In a real implementation, you might use different tokens for different resources, - // or refresh tokens when they're about to expire - return Task.FromResult(_apiKey); + Console.WriteLine($"Getting authentication token for {resourceUri}"); + + // Check if we have a valid cached token + string resourceKey = resourceUri.ToString(); + if (_tokenCache.TryGetValue(resourceKey, out var tokenInfo)) + { + Console.WriteLine("Using cached token"); + return tokenInfo.AccessToken; + } + + return string.Empty; } /// @@ -33,7 +50,7 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp { try { - // Use the updated AuthenticationChallengeHandler to handle the 401 challenge + // Use AuthenticationUtils to handle the 401 challenge var resourceMetadata = await AuthenticationUtils.ExtractProtectedResourceMetadata( response, _serverUrl, @@ -42,30 +59,37 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp // If we get here, the resource metadata is valid and matches our server Console.WriteLine($"Successfully validated resource metadata for: {resourceMetadata.Resource}"); - // For a real implementation, you would: - // 1. Use the metadata to get information about the authorization servers - // 2. Obtain a new token from one of those authorization servers - // 3. Store the new token for future requests - - // Example of what a real implementation might do: - /* + // Follow the authorization flow as described in the specs if (resourceMetadata.AuthorizationServers?.Count > 0) { + // Get the first authorization server var authServerUrl = resourceMetadata.AuthorizationServers[0]; + Console.WriteLine($"Using authorization server: {authServerUrl}"); + + // Fetch authorization server metadata var authServerMetadata = await AuthenticationUtils.FetchAuthorizationServerMetadataAsync( authServerUrl, cancellationToken); if (authServerMetadata != null) { - // Use auth server metadata to obtain a new token - // Store the token in _tokenCache - // Return true to indicate the unauthorized response was handled - return true; + // Perform the OAuth authorization code flow with PKCE + var token = await PerformAuthorizationCodeFlowAsync(authServerMetadata, resourceMetadata, cancellationToken); + + if (token != null) + { + // Store the token in the cache + string resourceKey = resourceMetadata.Resource.ToString(); + _tokenCache[resourceKey] = token; + Console.WriteLine("Successfully obtained a new token"); + return true; + } + } + else + { + Console.WriteLine("Failed to fetch authorization server metadata"); } } - */ - // For now, we still return false since we're not actually refreshing the token Console.WriteLine("API key is valid, but might not have sufficient permissions."); return false; } @@ -82,4 +106,161 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp return false; } } + + /// + /// Performs the OAuth authorization code flow with PKCE. + /// + /// The authorization server metadata. + /// The protected resource metadata. + /// A token to cancel the operation. + /// The token information if successful, otherwise null. + private async Task PerformAuthorizationCodeFlowAsync( + AuthorizationServerMetadata authServerMetadata, + ProtectedResourceMetadata resourceMetadata, + CancellationToken cancellationToken) + { + // Generate PKCE code challenge + var codeVerifier = GenerateCodeVerifier(); + var codeChallenge = GenerateCodeChallenge(codeVerifier); + + // In a real client, you would redirect the user to the authorization endpoint + // For this sample, we'll simulate the authorization code grant + Console.WriteLine("In a real app, the user would be redirected to the authorization URL:"); + + // Build the authorization URL + var authorizationUrl = BuildAuthorizationUrl(authServerMetadata, codeChallenge, resourceMetadata); + Console.WriteLine($"Authorization URL: {authorizationUrl}"); + + // In a real app, you would wait for the redirect with the authorization code + // For this sample, we'll simulate it + Console.WriteLine("Simulating authorization code grant (in a real app, user would interact with the auth server)"); + + // Simulate getting an authorization code (this would come from the redirect in a real app) + // NOTE: This is just for demonstration. In a real client, you'd parse the authorization code from the redirect + string simulatedAuthCode = "simulated_auth_code_would_come_from_redirect"; + + // Exchange the authorization code for tokens + return await ExchangeCodeForTokenAsync(authServerMetadata, simulatedAuthCode, codeVerifier, cancellationToken); + } + + /// + /// Builds the authorization URL for the authorization code flow. + /// + private Uri BuildAuthorizationUrl( + AuthorizationServerMetadata authServerMetadata, + string codeChallenge, + ProtectedResourceMetadata resourceMetadata) + { + var queryParams = HttpUtility.ParseQueryString(string.Empty); + queryParams["client_id"] = _clientId; + queryParams["redirect_uri"] = _redirectUri.ToString(); + queryParams["response_type"] = "code"; + queryParams["code_challenge"] = codeChallenge; + queryParams["code_challenge_method"] = "S256"; + + // Add scopes if available from resource metadata + if (resourceMetadata.ScopesSupported.Count > 0) + { + queryParams["scope"] = string.Join(" ", resourceMetadata.ScopesSupported); + } + + // Create the authorization URL + var uriBuilder = new UriBuilder(authServerMetadata.AuthorizationEndpoint); + uriBuilder.Query = queryParams.ToString(); + + return uriBuilder.Uri; + } + + /// + /// Exchanges an authorization code for an access token. + /// + private async Task ExchangeCodeForTokenAsync( + AuthorizationServerMetadata authServerMetadata, + string authorizationCode, + string codeVerifier, + CancellationToken cancellationToken) + { + using var httpClient = new HttpClient(); + + // Set up the request to the token endpoint + var requestContent = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = authorizationCode, + ["redirect_uri"] = _redirectUri.ToString(), + ["client_id"] = _clientId, + ["code_verifier"] = codeVerifier + }); + + // Add client authentication if we have a client secret + if (!string.IsNullOrEmpty(_clientSecret)) + { + var authHeader = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{_clientId}:{_clientSecret}")); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Basic", authHeader); + } + + try + { + // Make the token request + var response = await httpClient.PostAsync( + authServerMetadata.TokenEndpoint, + requestContent, + cancellationToken); + + if (response.IsSuccessStatusCode) + { + // Parse the token response + var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + var tokenResponse = JsonSerializer.Deserialize( + responseJson, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (tokenResponse != null) + { + // There was a valid token response + } + } + else + { + Console.WriteLine($"Token request failed: {response.StatusCode}"); + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + Console.WriteLine($"Error: {errorContent}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Exception during token exchange: {ex.Message}"); + } + + return null; + } + + /// + /// Generates a random code verifier for PKCE. + /// + private string GenerateCodeVerifier() + { + var bytes = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + /// + /// Generates a code challenge from a code verifier using SHA256. + /// + private string GenerateCodeChallenge(string codeVerifier) + { + using var sha256 = SHA256.Create(); + var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); + return Convert.ToBase64String(challengeBytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } } \ No newline at end of file diff --git a/samples/ProtectedMCPClient/TokenContainer.cs b/samples/ProtectedMCPClient/TokenContainer.cs new file mode 100644 index 00000000..b2a178f5 --- /dev/null +++ b/samples/ProtectedMCPClient/TokenContainer.cs @@ -0,0 +1,12 @@ +namespace ProtectedMCPClient; + +/// +/// Represents a token response from the OAuth server. +/// +internal class TokenContainer +{ + public string AccessToken { get; set; } = string.Empty; + public string? RefreshToken { get; set; } + public int ExpiresIn { get; set; } + public string TokenType { get; set; } = string.Empty; +} diff --git a/src/ModelContextProtocol/Authentication/IClientRegistrationProvider.cs b/src/ModelContextProtocol/Authentication/IClientRegistrationProvider.cs deleted file mode 100644 index 9dbc5ccc..00000000 --- a/src/ModelContextProtocol/Authentication/IClientRegistrationProvider.cs +++ /dev/null @@ -1,22 +0,0 @@ -using ModelContextProtocol.Types.Authentication; - -namespace ModelContextProtocol.Authentication; - -/// -/// Defines an interface for registering OAuth clients with authorization servers. -/// This is an extensibility point for client registration in MCP clients. -/// -public interface IClientRegistrationProvider -{ - /// - /// Registers a client with an OAuth authorization server. - /// - /// Metadata about the authorization server. - /// The client registration request data. - /// A token to cancel the operation. - /// The client registration response containing client credentials. - Task RegisterClientAsync( - AuthorizationServerMetadata authorizationServerMetadata, - ClientRegistrationRequest registrationRequest, - CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/src/ModelContextProtocol/Authentication/Types/ClientRegistrationRequest.cs b/src/ModelContextProtocol/Authentication/Types/ClientRegistrationRequest.cs deleted file mode 100644 index a3786d40..00000000 --- a/src/ModelContextProtocol/Authentication/Types/ClientRegistrationRequest.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Types.Authentication; - -/// -/// Represents the client registration request metadata. -/// -public class ClientRegistrationRequest -{ - /// - /// Array of redirection URI strings for use in redirect-based flows. - /// - [JsonPropertyName("redirect_uris")] - public List RedirectUris { get; set; } = new(); - - /// - /// String indicator of the requested authentication method for the token endpoint. - /// - [JsonPropertyName("token_endpoint_auth_method")] - public string? TokenEndpointAuthMethod { get; set; } - - /// - /// Array of OAuth 2.0 grant type strings that the client can use at the token endpoint. - /// - [JsonPropertyName("grant_types")] - public List? GrantTypes { get; set; } - - /// - /// Array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint. - /// - [JsonPropertyName("response_types")] - public List? ResponseTypes { get; set; } - - /// - /// Human-readable string name of the client to be presented to the end-user during authorization. - /// - [JsonPropertyName("client_name")] - public string? ClientName { get; set; } - - /// - /// URL string of a web page providing information about the client. - /// - [JsonPropertyName("client_uri")] - public string? ClientUri { get; set; } - - /// - /// URL string that references a logo for the client. - /// - [JsonPropertyName("logo_uri")] - public string? LogoUri { get; set; } - - /// - /// String containing a space-separated list of scope values that the client can use. - /// - [JsonPropertyName("scope")] - public string? Scope { get; set; } - - /// - /// Array of strings representing ways to contact people responsible for this client. - /// - [JsonPropertyName("contacts")] - public List? Contacts { get; set; } - - /// - /// URL string that points to a human-readable terms of service document for the client. - /// - [JsonPropertyName("tos_uri")] - public string? TosUri { get; set; } - - /// - /// URL string that points to a human-readable privacy policy document. - /// - [JsonPropertyName("policy_uri")] - public string? PolicyUri { get; set; } - - /// - /// URL string referencing the client's JSON Web Key (JWK) Set document. - /// - [JsonPropertyName("jwks_uri")] - public string? JwksUri { get; set; } - - /// - /// Client's JSON Web Key Set document value. - /// - [JsonPropertyName("jwks")] - public object? Jwks { get; set; } - - /// - /// A unique identifier string assigned by the client developer or software publisher. - /// - [JsonPropertyName("software_id")] - public string? SoftwareId { get; set; } - - /// - /// A version identifier string for the client software identified by software_id. - /// - [JsonPropertyName("software_version")] - public string? SoftwareVersion { get; set; } -} diff --git a/src/ModelContextProtocol/Authentication/Types/ClientRegistrationResponse.cs b/src/ModelContextProtocol/Authentication/Types/ClientRegistrationResponse.cs deleted file mode 100644 index 2ddd9ed2..00000000 --- a/src/ModelContextProtocol/Authentication/Types/ClientRegistrationResponse.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Types.Authentication; - -/// -/// Represents the client registration response. -/// -public class ClientRegistrationResponse -{ - /// - /// OAuth 2.0 client identifier string. - /// - [JsonPropertyName("client_id")] - public string ClientId { get; set; } = string.Empty; - - /// - /// OAuth 2.0 client secret string. - /// - [JsonPropertyName("client_secret")] - public string? ClientSecret { get; set; } - - /// - /// Time at which the client identifier was issued. - /// - [JsonPropertyName("client_id_issued_at")] - public long? ClientIdIssuedAt { get; set; } - - /// - /// Time at which the client secret will expire or 0 if it will not expire. - /// - [JsonPropertyName("client_secret_expires_at")] - public long? ClientSecretExpiresAt { get; set; } -} \ No newline at end of file diff --git a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs index 14b98853..9923bec4 100644 --- a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs +++ b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs @@ -125,8 +125,6 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(IReadOnlyDictionary))] [JsonSerializable(typeof(AuthorizationServerMetadata))] - [JsonSerializable(typeof(ClientRegistrationRequest))] - [JsonSerializable(typeof(ClientRegistrationResponse))] [JsonSerializable(typeof(ProtectedResourceMetadata))] [ExcludeFromCodeCoverage] From 4073efd0ca588f8b8cac64eacc279aabbc196e31 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 11:08:30 -0700 Subject: [PATCH 070/128] Complete PRM doc properties. Update for consistency --- .../ProtectedMCPClient.csproj | 2 +- .../SimpleAccessTokenProvider.cs | 10 +- .../Types/AuthorizationServerMetadata.cs | 0 .../{ => Types}/TokenContainer.cs | 2 +- .../Utils/AuthorizationServerUtils.cs | 69 +++++++++++ ...r.cs => AuthorizationDelegatingHandler.cs} | 16 +-- ...cationUtils.cs => AuthorizationHelpers.cs} | 64 +---------- .../Authentication/HttpClientExtensions.cs | 4 +- ...cessTokenProvider.cs => ITokenProvider.cs} | 4 +- .../Types/ProtectedResourceMetadata.cs | 108 +++++++++++++++++- .../Utils/Json/McpJsonUtilities.cs | 1 - 11 files changed, 197 insertions(+), 83 deletions(-) rename {src/ModelContextProtocol/Authentication => samples/ProtectedMCPClient}/Types/AuthorizationServerMetadata.cs (100%) rename samples/ProtectedMCPClient/{ => Types}/TokenContainer.cs (89%) create mode 100644 samples/ProtectedMCPClient/Utils/AuthorizationServerUtils.cs rename src/ModelContextProtocol/Authentication/{AuthenticationDelegatingHandler.cs => AuthorizationDelegatingHandler.cs} (83%) rename src/ModelContextProtocol/Authentication/{AuthenticationUtils.cs => AuthorizationHelpers.cs} (73%) rename src/ModelContextProtocol/Authentication/{IAccessTokenProvider.cs => ITokenProvider.cs} (88%) diff --git a/samples/ProtectedMCPClient/ProtectedMCPClient.csproj b/samples/ProtectedMCPClient/ProtectedMCPClient.csproj index f090cca5..cc60ff80 100644 --- a/samples/ProtectedMCPClient/ProtectedMCPClient.csproj +++ b/samples/ProtectedMCPClient/ProtectedMCPClient.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs index 5a5523c5..869e93ef 100644 --- a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs +++ b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs @@ -1,5 +1,7 @@ using ModelContextProtocol.Authentication; using ModelContextProtocol.Types.Authentication; +using ProtectedMCPClient.Types; +using ProtectedMCPClient.Utils; using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Security.Cryptography; @@ -13,7 +15,7 @@ namespace ProtectedMCPClient; /// A simple implementation of IAccessTokenProvider that uses a fixed API key. /// This is just for demonstration purposes. /// -public partial class SimpleAccessTokenProvider : IAccessTokenProvider +public partial class SimpleAccessTokenProvider : ITokenProvider { private readonly ConcurrentDictionary _tokenCache = new(); private readonly Uri _serverUrl; @@ -30,7 +32,7 @@ public SimpleAccessTokenProvider(Uri serverUrl, string clientId = "demo-client", } /// - public async Task GetAuthenticationTokenAsync(Uri resourceUri, CancellationToken cancellationToken = default) + public async Task GetTokenAsync(Uri resourceUri, CancellationToken cancellationToken = default) { Console.WriteLine($"Getting authentication token for {resourceUri}"); @@ -51,7 +53,7 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp try { // Use AuthenticationUtils to handle the 401 challenge - var resourceMetadata = await AuthenticationUtils.ExtractProtectedResourceMetadata( + var resourceMetadata = await AuthorizationHelpers.ExtractProtectedResourceMetadata( response, _serverUrl, cancellationToken); @@ -67,7 +69,7 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp Console.WriteLine($"Using authorization server: {authServerUrl}"); // Fetch authorization server metadata - var authServerMetadata = await AuthenticationUtils.FetchAuthorizationServerMetadataAsync( + var authServerMetadata = await AuthorizationServerUtils.FetchAuthorizationServerMetadataAsync( authServerUrl, cancellationToken); if (authServerMetadata != null) diff --git a/src/ModelContextProtocol/Authentication/Types/AuthorizationServerMetadata.cs b/samples/ProtectedMCPClient/Types/AuthorizationServerMetadata.cs similarity index 100% rename from src/ModelContextProtocol/Authentication/Types/AuthorizationServerMetadata.cs rename to samples/ProtectedMCPClient/Types/AuthorizationServerMetadata.cs diff --git a/samples/ProtectedMCPClient/TokenContainer.cs b/samples/ProtectedMCPClient/Types/TokenContainer.cs similarity index 89% rename from samples/ProtectedMCPClient/TokenContainer.cs rename to samples/ProtectedMCPClient/Types/TokenContainer.cs index b2a178f5..e5934e0a 100644 --- a/samples/ProtectedMCPClient/TokenContainer.cs +++ b/samples/ProtectedMCPClient/Types/TokenContainer.cs @@ -1,4 +1,4 @@ -namespace ProtectedMCPClient; +namespace ProtectedMCPClient.Types; /// /// Represents a token response from the OAuth server. diff --git a/samples/ProtectedMCPClient/Utils/AuthorizationServerUtils.cs b/samples/ProtectedMCPClient/Utils/AuthorizationServerUtils.cs new file mode 100644 index 00000000..0bff7c13 --- /dev/null +++ b/samples/ProtectedMCPClient/Utils/AuthorizationServerUtils.cs @@ -0,0 +1,69 @@ +using ModelContextProtocol.Types.Authentication; +using System.Text.Json; + +namespace ProtectedMCPClient.Utils +{ + internal class AuthorizationServerUtils + { + /// + /// Fetches the authorization server metadata from a server URL, trying both well-known endpoints. + /// + /// The base URL of the authorization server. + /// A token to cancel the operation. + /// The fetched AuthorizationServerMetadata, or null if it couldn't be fetched. + public static async Task FetchAuthorizationServerMetadataAsync( + Uri authorizationServerUrl, + CancellationToken cancellationToken = default) + { + using var httpClient = new HttpClient(); + + // Try OpenID Connect configuration endpoint first, then OAuth Authorization Server Metadata endpoint + string[] wellKnownEndpoints = { + "/.well-known/openid-configuration", + "/.well-known/oauth-authorization-server" + }; + + foreach (var endpoint in wellKnownEndpoints) + { + var metadataUrl = new Uri(authorizationServerUrl, endpoint); + var metadata = await TryFetchMetadataAsync(httpClient, metadataUrl, cancellationToken); + if (metadata != null) + { + return metadata; + } + } + + return null; + } + + /// + /// Attempts to fetch metadata from a specific URL. + /// + /// The HTTP client to use for the request. + /// The URL to fetch metadata from. + /// A token to cancel the operation. + /// The metadata if successful, or null if the fetch fails. + private static async Task TryFetchMetadataAsync( + HttpClient httpClient, + Uri metadataUrl, + CancellationToken cancellationToken) + { + try + { + var response = await httpClient.GetAsync(metadataUrl, cancellationToken); + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStreamAsync(); + return await JsonSerializer.DeserializeAsync(content, new JsonSerializerOptions() { WriteIndented = true }, + cancellationToken); + } + } + catch (Exception) + { + // Ignore exceptions and return null + } + + return null; + } + } +} diff --git a/src/ModelContextProtocol/Authentication/AuthenticationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs similarity index 83% rename from src/ModelContextProtocol/Authentication/AuthenticationDelegatingHandler.cs rename to src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index 26f63fca..94e4cdb7 100644 --- a/src/ModelContextProtocol/Authentication/AuthenticationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -5,17 +5,17 @@ namespace ModelContextProtocol.Authentication; /// /// A delegating handler that adds authentication tokens to requests and handles 401 responses. /// -internal class AuthenticationDelegatingHandler : DelegatingHandler +internal class AuthorizationDelegatingHandler : DelegatingHandler { - private readonly IAccessTokenProvider _tokenProvider; + private readonly ITokenProvider _tokenProvider; private readonly string _scheme; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The provider that supplies authentication tokens. /// The authentication scheme to use, e.g., "Bearer". - public AuthenticationDelegatingHandler(IAccessTokenProvider tokenProvider, string scheme) + public AuthorizationDelegatingHandler(ITokenProvider tokenProvider, string scheme) { _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider)); _scheme = scheme ?? throw new ArgumentNullException(nameof(scheme)); @@ -29,7 +29,7 @@ protected override async Task SendAsync(HttpRequestMessage // Add the authentication token to the request if not already present if (request.Headers.Authorization == null) { - await AddAuthenticationHeaderAsync(request, cancellationToken); + await AddAuthorizationHeaderAsync(request, cancellationToken); } // Send the request through the inner handler @@ -49,7 +49,7 @@ protected override async Task SendAsync(HttpRequestMessage var retryRequest = await CloneHttpRequestMessageAsync(request); // Get a new token - await AddAuthenticationHeaderAsync(retryRequest, cancellationToken); + await AddAuthorizationHeaderAsync(retryRequest, cancellationToken); // Send the retry request return await base.SendAsync(retryRequest, cancellationToken); @@ -62,11 +62,11 @@ protected override async Task SendAsync(HttpRequestMessage /// /// Adds an authorization header to the request. /// - private async Task AddAuthenticationHeaderAsync(HttpRequestMessage request, CancellationToken cancellationToken) + private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (request.RequestUri != null) { - var token = await _tokenProvider.GetAuthenticationTokenAsync(request.RequestUri, cancellationToken); + var token = await _tokenProvider.GetTokenAsync(request.RequestUri, cancellationToken); if (!string.IsNullOrEmpty(token)) { request.Headers.Authorization = new AuthenticationHeaderValue(_scheme, token); diff --git a/src/ModelContextProtocol/Authentication/AuthenticationUtils.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs similarity index 73% rename from src/ModelContextProtocol/Authentication/AuthenticationUtils.cs rename to src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index bedc76ff..5f88b98e 100644 --- a/src/ModelContextProtocol/Authentication/AuthenticationUtils.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -7,7 +7,7 @@ namespace ModelContextProtocol.Authentication; /// /// Provides utility methods for handling authentication in MCP clients. /// -public static class AuthenticationUtils +public static class AuthorizationHelpers { /// /// Fetches the protected resource metadata from the provided URL. @@ -36,68 +36,6 @@ public static class AuthenticationUtils } } - /// - /// Fetches the authorization server metadata from a server URL, trying both well-known endpoints. - /// - /// The base URL of the authorization server. - /// A token to cancel the operation. - /// The fetched AuthorizationServerMetadata, or null if it couldn't be fetched. - public static async Task FetchAuthorizationServerMetadataAsync( - Uri authorizationServerUrl, - CancellationToken cancellationToken = default) - { - using var httpClient = new HttpClient(); - - // Try OpenID Connect configuration endpoint first, then OAuth Authorization Server Metadata endpoint - string[] wellKnownEndpoints = { - "/.well-known/openid-configuration", - "/.well-known/oauth-authorization-server" - }; - - foreach (var endpoint in wellKnownEndpoints) - { - var metadataUrl = new Uri(authorizationServerUrl, endpoint); - var metadata = await TryFetchMetadataAsync(httpClient, metadataUrl, cancellationToken); - if (metadata != null) - { - return metadata; - } - } - - return null; - } - - /// - /// Attempts to fetch metadata from a specific URL. - /// - /// The HTTP client to use for the request. - /// The URL to fetch metadata from. - /// A token to cancel the operation. - /// The metadata if successful, or null if the fetch fails. - private static async Task TryFetchMetadataAsync( - HttpClient httpClient, - Uri metadataUrl, - CancellationToken cancellationToken) - { - try - { - var response = await httpClient.GetAsync(metadataUrl, cancellationToken); - if (response.IsSuccessStatusCode) - { - var content = await response.Content.ReadAsStreamAsync(); - return await JsonSerializer.DeserializeAsync(content, - McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata, - cancellationToken); - } - } - catch (Exception) - { - // Ignore exceptions and return null - } - - return null; - } - /// /// Verifies that the resource URI in the metadata matches the server URL. /// diff --git a/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs b/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs index 129bf921..47de2115 100644 --- a/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs +++ b/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs @@ -20,7 +20,7 @@ public static class HttpClientExtensions /// Retries the request with the new token if token refresh is successful /// /// - public static HttpClient UseAuthenticationProvider(this HttpClient httpClient, IAccessTokenProvider tokenProvider, string scheme = "Bearer") + public static HttpClient UseAuthenticationProvider(this HttpClient httpClient, ITokenProvider tokenProvider, string scheme = "Bearer") { if (httpClient == null) throw new ArgumentNullException(nameof(httpClient)); @@ -40,7 +40,7 @@ public static HttpClient UseAuthenticationProvider(this HttpClient httpClient, I } // Create our authentication delegating handler with the token provider - var authHandler = new AuthenticationDelegatingHandler(tokenProvider, scheme) + var authHandler = new AuthorizationDelegatingHandler(tokenProvider, scheme) { InnerHandler = handler }; diff --git a/src/ModelContextProtocol/Authentication/IAccessTokenProvider.cs b/src/ModelContextProtocol/Authentication/ITokenProvider.cs similarity index 88% rename from src/ModelContextProtocol/Authentication/IAccessTokenProvider.cs rename to src/ModelContextProtocol/Authentication/ITokenProvider.cs index ceda55a7..8edafde2 100644 --- a/src/ModelContextProtocol/Authentication/IAccessTokenProvider.cs +++ b/src/ModelContextProtocol/Authentication/ITokenProvider.cs @@ -4,7 +4,7 @@ namespace ModelContextProtocol.Authentication; /// Defines an interface for providing authentication for requests. /// This is the main extensibility point for authentication in MCP clients. /// -public interface IAccessTokenProvider +public interface ITokenProvider { /// /// Gets an authentication token or credential for authenticating requests to a resource. @@ -12,7 +12,7 @@ public interface IAccessTokenProvider /// The URI of the resource requiring authentication. /// A token to cancel the operation. /// An authentication token string or null if no token could be obtained. - Task GetAuthenticationTokenAsync(Uri resourceUri, CancellationToken cancellationToken = default); + Task GetTokenAsync(Uri resourceUri, CancellationToken cancellationToken = default); /// /// Handles a 401 Unauthorized response from a resource. diff --git a/src/ModelContextProtocol/Authentication/Types/ProtectedResourceMetadata.cs b/src/ModelContextProtocol/Authentication/Types/ProtectedResourceMetadata.cs index aef75cc5..d429ab58 100644 --- a/src/ModelContextProtocol/Authentication/Types/ProtectedResourceMetadata.cs +++ b/src/ModelContextProtocol/Authentication/Types/ProtectedResourceMetadata.cs @@ -3,37 +3,143 @@ namespace ModelContextProtocol.Types.Authentication; /// -/// Represents the resource metadata for OAuth authorization. +/// Represents the resource metadata for OAuth authorization as defined in RFC 9396. +/// Defined by RFC 9728. /// public class ProtectedResourceMetadata { /// /// The resource URI. /// + /// + /// REQUIRED. The protected resource's resource identifier. + /// [JsonPropertyName("resource")] public Uri Resource { get; set; } = null!; /// /// The list of authorization server URIs. /// + /// + /// OPTIONAL. JSON array containing a list of OAuth authorization server issuer identifiers + /// for authorization servers that can be used with this protected resource. + /// [JsonPropertyName("authorization_servers")] public List AuthorizationServers { get; set; } = new(); /// /// The supported bearer token methods. /// + /// + /// OPTIONAL. JSON array containing a list of the supported methods of sending an OAuth 2.0 bearer token + /// to the protected resource. Defined values are ["header", "body", "query"]. + /// [JsonPropertyName("bearer_methods_supported")] public List BearerMethodsSupported { get; set; } = new(); /// /// The supported scopes. /// + /// + /// RECOMMENDED. JSON array containing a list of scope values that are used in authorization + /// requests to request access to this protected resource. + /// [JsonPropertyName("scopes_supported")] public List ScopesSupported { get; set; } = new(); + /// + /// URL of the protected resource's JSON Web Key (JWK) Set document. + /// + /// + /// OPTIONAL. This contains public keys belonging to the protected resource, such as signing key(s) + /// that the resource server uses to sign resource responses. This URL MUST use the https scheme. + /// + [JsonPropertyName("jwks_uri")] + public Uri? JwksUri { get; set; } + + /// + /// List of the JWS signing algorithms supported by the protected resource for signing resource responses. + /// + /// + /// OPTIONAL. JSON array containing a list of the JWS signing algorithms (alg values) supported by the protected resource + /// for signing resource responses. No default algorithms are implied if this entry is omitted. The value none MUST NOT be used. + /// + [JsonPropertyName("resource_signing_alg_values_supported")] + public List? ResourceSigningAlgValuesSupported { get; set; } + + /// + /// Human-readable name of the protected resource intended for display to the end user. + /// + /// + /// RECOMMENDED. It is recommended that protected resource metadata include this field. + /// The value of this field MAY be internationalized. + /// + [JsonPropertyName("resource_name")] + public string? ResourceName { get; set; } + /// /// The URI to the resource documentation. /// + /// + /// OPTIONAL. URL of a page containing human-readable information that developers might want or need to know + /// when using the protected resource. + /// [JsonPropertyName("resource_documentation")] public Uri? ResourceDocumentation { get; set; } + + /// + /// URL of a page containing human-readable information about the protected resource's requirements. + /// + /// + /// OPTIONAL. Information about how the client can use the data provided by the protected resource. + /// + [JsonPropertyName("resource_policy_uri")] + public Uri? ResourcePolicyUri { get; set; } + + /// + /// URL of a page containing human-readable information about the protected resource's terms of service. + /// + /// + /// OPTIONAL. The value of this field MAY be internationalized. + /// + [JsonPropertyName("resource_tos_uri")] + public Uri? ResourceTosUri { get; set; } + + /// + /// Boolean value indicating protected resource support for mutual-TLS client certificate-bound access tokens. + /// + /// + /// OPTIONAL. If omitted, the default value is false. + /// + [JsonPropertyName("tls_client_certificate_bound_access_tokens")] + public bool? TlsClientCertificateBoundAccessTokens { get; set; } + + /// + /// List of the authorization details type values supported by the resource server. + /// + /// + /// OPTIONAL. JSON array containing a list of the authorization details type values supported by the resource server + /// when the authorization_details request parameter is used. + /// + [JsonPropertyName("authorization_details_types_supported")] + public List? AuthorizationDetailsTypesSupported { get; set; } + + /// + /// List of the JWS algorithm values supported by the resource server for validating DPoP proof JWTs. + /// + /// + /// OPTIONAL. JSON array containing a list of the JWS alg values supported by the resource server + /// for validating Demonstrating Proof of Possession (DPoP) proof JWTs. + /// + [JsonPropertyName("dpop_signing_alg_values_supported")] + public List? DpopSigningAlgValuesSupported { get; set; } + + /// + /// Boolean value specifying whether the protected resource always requires the use of DPoP-bound access tokens. + /// + /// + /// OPTIONAL. If omitted, the default value is false. + /// + [JsonPropertyName("dpop_bound_access_tokens_required")] + public bool? DpopBoundAccessTokensRequired { get; set; } } \ No newline at end of file diff --git a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs index 9923bec4..010e0332 100644 --- a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs +++ b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs @@ -124,7 +124,6 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(UnsubscribeRequestParams))] [JsonSerializable(typeof(IReadOnlyDictionary))] - [JsonSerializable(typeof(AuthorizationServerMetadata))] [JsonSerializable(typeof(ProtectedResourceMetadata))] [ExcludeFromCodeCoverage] From 2e06f590a9795d10b455be84756aa2bb013a7c6e Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 11:12:56 -0700 Subject: [PATCH 071/128] Update for consistency --- samples/ProtectedMCPClient/Program.cs | 26 +++---------------- .../SimpleAccessTokenProvider.cs | 4 +-- .../AuthorizationDelegatingHandler.cs | 6 ++--- .../Authentication/HttpClientExtensions.cs | 2 +- .../Authentication/ITokenProvider.cs | 4 +-- 5 files changed, 11 insertions(+), 31 deletions(-) diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 8a8688b7..56179ad5 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -1,7 +1,6 @@ using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol.Transport; -using System.Diagnostics; namespace ProtectedMCPClient; @@ -9,47 +8,29 @@ class Program { static async Task Main(string[] args) { - Console.WriteLine("MCP Secure Weather Client with Authentication"); - Console.WriteLine("=============================================="); + Console.WriteLine("Protected MCP Weather Server"); Console.WriteLine(); - // Create a standard HttpClient with authentication configured - var serverUrl = "http://localhost:7071/sse"; // Default server URL + var serverUrl = "http://localhost:7071/sse"; - // Allow the user to specify a different server URL - Console.WriteLine($"Server URL (press Enter for default: {serverUrl}):"); - var userInput = Console.ReadLine(); - if (!string.IsNullOrWhiteSpace(userInput)) - { - serverUrl = userInput; - } - - // Create a single HttpClient with authentication configured var tokenProvider = new SimpleAccessTokenProvider(new Uri(serverUrl)); - var httpClient = new HttpClient().UseAuthenticationProvider(tokenProvider); + var httpClient = new HttpClient().UseMcpAuthorizationProvider(tokenProvider); Console.WriteLine(); Console.WriteLine($"Connecting to weather server at {serverUrl}..."); - Console.WriteLine("When prompted for authorization, the challenge will be verified automatically."); - Console.WriteLine("If required, you'll be guided through any necessary authentication steps."); - Console.WriteLine(); try { - // Create SseClientTransportOptions with the server URL var transportOptions = new SseClientTransportOptions { Endpoint = new Uri(serverUrl), Name = "Secure Weather Client" }; - // Create SseClientTransport with our authenticated HTTP client var transport = new SseClientTransport(transportOptions, httpClient); - // Create an MCP client using the factory method with our transport var client = await McpClientFactory.CreateAsync(transport); - // Get the list of available tools var tools = await client.ListToolsAsync(); if (tools.Count == 0) { @@ -60,7 +41,6 @@ static async Task Main(string[] args) Console.WriteLine($"Found {tools.Count} tools on the server."); Console.WriteLine(); - // Call the protected-data tool which requires authentication if (tools.Any(t => t.Name == "protected-data")) { Console.WriteLine("Calling protected-data tool..."); diff --git a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs index 869e93ef..9b33a666 100644 --- a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs +++ b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs @@ -15,7 +15,7 @@ namespace ProtectedMCPClient; /// A simple implementation of IAccessTokenProvider that uses a fixed API key. /// This is just for demonstration purposes. /// -public partial class SimpleAccessTokenProvider : ITokenProvider +public partial class SimpleAccessTokenProvider : IMcpAuthorizationProvider { private readonly ConcurrentDictionary _tokenCache = new(); private readonly Uri _serverUrl; @@ -32,7 +32,7 @@ public SimpleAccessTokenProvider(Uri serverUrl, string clientId = "demo-client", } /// - public async Task GetTokenAsync(Uri resourceUri, CancellationToken cancellationToken = default) + public async Task GetCredentialAsync(Uri resourceUri, CancellationToken cancellationToken = default) { Console.WriteLine($"Getting authentication token for {resourceUri}"); diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index 94e4cdb7..a1fe85f7 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -7,7 +7,7 @@ namespace ModelContextProtocol.Authentication; /// internal class AuthorizationDelegatingHandler : DelegatingHandler { - private readonly ITokenProvider _tokenProvider; + private readonly IMcpAuthorizationProvider _tokenProvider; private readonly string _scheme; /// @@ -15,7 +15,7 @@ internal class AuthorizationDelegatingHandler : DelegatingHandler /// /// The provider that supplies authentication tokens. /// The authentication scheme to use, e.g., "Bearer". - public AuthorizationDelegatingHandler(ITokenProvider tokenProvider, string scheme) + public AuthorizationDelegatingHandler(IMcpAuthorizationProvider tokenProvider, string scheme) { _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider)); _scheme = scheme ?? throw new ArgumentNullException(nameof(scheme)); @@ -66,7 +66,7 @@ private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, Cance { if (request.RequestUri != null) { - var token = await _tokenProvider.GetTokenAsync(request.RequestUri, cancellationToken); + var token = await _tokenProvider.GetCredentialAsync(request.RequestUri, cancellationToken); if (!string.IsNullOrEmpty(token)) { request.Headers.Authorization = new AuthenticationHeaderValue(_scheme, token); diff --git a/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs b/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs index 47de2115..cc99fa90 100644 --- a/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs +++ b/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs @@ -20,7 +20,7 @@ public static class HttpClientExtensions /// Retries the request with the new token if token refresh is successful /// /// - public static HttpClient UseAuthenticationProvider(this HttpClient httpClient, ITokenProvider tokenProvider, string scheme = "Bearer") + public static HttpClient UseMcpAuthorizationProvider(this HttpClient httpClient, IMcpAuthorizationProvider tokenProvider, string scheme = "Bearer") { if (httpClient == null) throw new ArgumentNullException(nameof(httpClient)); diff --git a/src/ModelContextProtocol/Authentication/ITokenProvider.cs b/src/ModelContextProtocol/Authentication/ITokenProvider.cs index 8edafde2..73f5620e 100644 --- a/src/ModelContextProtocol/Authentication/ITokenProvider.cs +++ b/src/ModelContextProtocol/Authentication/ITokenProvider.cs @@ -4,7 +4,7 @@ namespace ModelContextProtocol.Authentication; /// Defines an interface for providing authentication for requests. /// This is the main extensibility point for authentication in MCP clients. /// -public interface ITokenProvider +public interface IMcpAuthorizationProvider { /// /// Gets an authentication token or credential for authenticating requests to a resource. @@ -12,7 +12,7 @@ public interface ITokenProvider /// The URI of the resource requiring authentication. /// A token to cancel the operation. /// An authentication token string or null if no token could be obtained. - Task GetTokenAsync(Uri resourceUri, CancellationToken cancellationToken = default); + Task GetCredentialAsync(Uri resourceUri, CancellationToken cancellationToken = default); /// /// Handles a 401 Unauthorized response from a resource. From 2d7da73cf6048af538c8867aae6907fb99928ca6 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 11:23:12 -0700 Subject: [PATCH 072/128] Work on the basic OAuth implementation in sample --- samples/ProtectedMCPClient/Program.cs | 2 +- .../SimpleAccessTokenProvider.cs | 4 +-- .../Utils/AuthorizationServerUtils.cs | 34 +++++++++++++------ 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 56179ad5..5af1420a 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -13,7 +13,7 @@ static async Task Main(string[] args) var serverUrl = "http://localhost:7071/sse"; - var tokenProvider = new SimpleAccessTokenProvider(new Uri(serverUrl)); + var tokenProvider = new BasicOAuthAuthorizationProvider(new Uri(serverUrl)); var httpClient = new HttpClient().UseMcpAuthorizationProvider(tokenProvider); Console.WriteLine(); diff --git a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs index 9b33a666..61add8e5 100644 --- a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs +++ b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs @@ -15,7 +15,7 @@ namespace ProtectedMCPClient; /// A simple implementation of IAccessTokenProvider that uses a fixed API key. /// This is just for demonstration purposes. /// -public partial class SimpleAccessTokenProvider : IMcpAuthorizationProvider +public partial class BasicOAuthAuthorizationProvider : IMcpAuthorizationProvider { private readonly ConcurrentDictionary _tokenCache = new(); private readonly Uri _serverUrl; @@ -23,7 +23,7 @@ public partial class SimpleAccessTokenProvider : IMcpAuthorizationProvider private readonly string _clientSecret; private readonly Uri _redirectUri; - public SimpleAccessTokenProvider(Uri serverUrl, string clientId = "demo-client", string clientSecret = "", Uri? redirectUri = null) + public BasicOAuthAuthorizationProvider(Uri serverUrl, string clientId = "demo-client", string clientSecret = "", Uri? redirectUri = null) { _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); _clientId = clientId; diff --git a/samples/ProtectedMCPClient/Utils/AuthorizationServerUtils.cs b/samples/ProtectedMCPClient/Utils/AuthorizationServerUtils.cs index 0bff7c13..eda1cb36 100644 --- a/samples/ProtectedMCPClient/Utils/AuthorizationServerUtils.cs +++ b/samples/ProtectedMCPClient/Utils/AuthorizationServerUtils.cs @@ -16,16 +16,26 @@ internal class AuthorizationServerUtils CancellationToken cancellationToken = default) { using var httpClient = new HttpClient(); - + + // Make sure the base URL ends with a slash to correctly append well-known paths + string baseUrl = authorizationServerUrl.ToString(); + if (!baseUrl.EndsWith("/")) + { + baseUrl += "/"; + } + // Try OpenID Connect configuration endpoint first, then OAuth Authorization Server Metadata endpoint - string[] wellKnownEndpoints = { - "/.well-known/openid-configuration", - "/.well-known/oauth-authorization-server" - }; + string[] wellKnownPaths = { + ".well-known/openid-configuration", + ".well-known/oauth-authorization-server" + }; - foreach (var endpoint in wellKnownEndpoints) + foreach (var path in wellKnownPaths) { - var metadataUrl = new Uri(authorizationServerUrl, endpoint); + // Simply combine the base URL (now with trailing slash) with the path (without leading slash) + var metadataUrl = new Uri(baseUrl + path); + Console.WriteLine($"Trying authorization server metadata endpoint: {metadataUrl}"); + var metadata = await TryFetchMetadataAsync(httpClient, metadataUrl, cancellationToken); if (metadata != null) { @@ -54,13 +64,17 @@ internal class AuthorizationServerUtils if (response.IsSuccessStatusCode) { var content = await response.Content.ReadAsStreamAsync(); - return await JsonSerializer.DeserializeAsync(content, new JsonSerializerOptions() { WriteIndented = true }, + return await JsonSerializer.DeserializeAsync(content, + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }, cancellationToken); } } - catch (Exception) + catch (Exception ex) { - // Ignore exceptions and return null + Console.WriteLine($"Error fetching metadata from {metadataUrl}: {ex.Message}"); } return null; From 10cf1f73674e02524609823c07e6529de705773d Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 12:00:58 -0700 Subject: [PATCH 073/128] Token acquisition logic --- .../BasicOAuthAuthorizationProvider.cs | 600 ++++++++++++++++++ samples/ProtectedMCPClient/Program.cs | 6 +- .../SimpleAccessTokenProvider.cs | 268 -------- .../Types/TokenContainer.cs | 10 + 4 files changed, 615 insertions(+), 269 deletions(-) create mode 100644 samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs delete mode 100644 samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs new file mode 100644 index 00000000..7783dd9c --- /dev/null +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -0,0 +1,600 @@ +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Types.Authentication; +using ProtectedMCPClient.Types; +using ProtectedMCPClient.Utils; +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Web; + +namespace ProtectedMCPClient; + +/// +/// A simple implementation of an OAuth authorization provider for MCP. +/// +/// +/// Initializes a new instance of the class. +/// +/// The server URL. +/// The OAuth client ID. +/// The OAuth client secret. +/// The OAuth redirect URI. +/// The OAuth scopes required by the application. +public partial class BasicOAuthAuthorizationProvider( + Uri serverUrl, + string clientId = "demo-client", + string clientSecret = "", + Uri? redirectUri = null, + IEnumerable? scopes = null) : IMcpAuthorizationProvider +{ + private readonly ConcurrentDictionary _tokenCache = new(); + private readonly Uri _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); + private readonly Uri _redirectUri = redirectUri ?? new Uri("http://localhost:8080/callback"); + private readonly IEnumerable _scopes = scopes ?? Array.Empty(); + + /// + public async Task GetCredentialAsync(Uri resourceUri, CancellationToken cancellationToken = default) + { + Console.WriteLine($"Getting authentication token for {resourceUri}"); + + // Check if we have a valid cached token + string resourceKey = resourceUri.ToString(); + if (_tokenCache.TryGetValue(resourceKey, out var tokenInfo)) + { + // Check if the token is still valid or needs to be refreshed + if (tokenInfo.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(5)) // 5-minute buffer + { + Console.WriteLine("Using cached token"); + return tokenInfo.AccessToken; + } + else if (!string.IsNullOrEmpty(tokenInfo.RefreshToken)) + { + Console.WriteLine("Token expired, attempting to refresh"); + + // Get the authorization server metadata for the resource + var resourceMetadata = await GetResourceMetadataAsync(resourceUri, cancellationToken); + if (resourceMetadata?.AuthorizationServers?.Count > 0) + { + var authServerUrl = resourceMetadata.AuthorizationServers[0]; + var authServerMetadata = await AuthorizationServerUtils.FetchAuthorizationServerMetadataAsync( + authServerUrl, cancellationToken); + + if (authServerMetadata != null) + { + // Refresh the token + var refreshedToken = await RefreshTokenAsync( + authServerMetadata, + tokenInfo.RefreshToken, + cancellationToken); + + if (refreshedToken != null) + { + _tokenCache[resourceKey] = refreshedToken; + Console.WriteLine("Token refreshed successfully"); + return refreshedToken.AccessToken; + } + else + { + Console.WriteLine("Token refresh failed, will need to re-authenticate"); + } + } + } + } + else + { + Console.WriteLine("Token expired and no refresh token available"); + } + + // Remove expired token from cache + _tokenCache.TryRemove(resourceKey, out _); + } + + // We don't have a valid token and need to get a new one + Console.WriteLine("No valid token available"); + return null; + } + + /// + /// Refreshes an OAuth token using the refresh token. + /// + /// The authorization server metadata. + /// The refresh token to use. + /// A token to cancel the operation. + /// The new token information if successful, otherwise null. + private async Task RefreshTokenAsync( + AuthorizationServerMetadata authServerMetadata, + string refreshToken, + CancellationToken cancellationToken) + { + using var httpClient = new HttpClient(); + + // Set up the request to the token endpoint + var requestContent = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["client_id"] = clientId + }); + + // Add client authentication if we have a client secret + if (!string.IsNullOrEmpty(clientSecret)) + { + var authHeader = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Basic", authHeader); + } + + try + { + // Make the token refresh request + var response = await httpClient.PostAsync( + authServerMetadata.TokenEndpoint, + requestContent, + cancellationToken); + + if (response.IsSuccessStatusCode) + { + // Parse the token response + var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + var tokenResponse = JsonSerializer.Deserialize( + responseJson, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (tokenResponse != null) + { + // Set the time when the token was obtained + tokenResponse.ObtainedAt = DateTimeOffset.UtcNow; + + // Calculate expiration time if not set + if (tokenResponse.ExpiresIn > 0 && tokenResponse.ExpiresAt == default) + { + tokenResponse.ExpiresAt = tokenResponse.ObtainedAt.AddSeconds(tokenResponse.ExpiresIn); + } + + // Preserve the refresh token if the response doesn't include a new one + if (string.IsNullOrEmpty(tokenResponse.RefreshToken)) + { + tokenResponse.RefreshToken = refreshToken; + } + + return tokenResponse; + } + } + else + { + Console.WriteLine($"Token refresh failed: {response.StatusCode}"); + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + Console.WriteLine($"Error: {errorContent}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Exception during token refresh: {ex.Message}"); + } + + return null; + } + + /// + /// Gets the metadata for a protected resource. + /// + /// The URI of the protected resource. + /// A token to cancel the operation. + /// The protected resource metadata. + private async Task GetResourceMetadataAsync(Uri resourceUri, CancellationToken cancellationToken) + { + try + { + using var httpClient = new HttpClient(); + + // Make a HEAD request to the resource to get the WWW-Authenticate header + var request = new HttpRequestMessage(HttpMethod.Head, resourceUri); + var response = await httpClient.SendAsync(request, cancellationToken); + + // Handle 401 Unauthorized response, which should contain the challenge + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + return await AuthorizationHelpers.ExtractProtectedResourceMetadata( + response, + _serverUrl, + cancellationToken); + } + else + { + Console.WriteLine($"Resource request did not return expected 401 status: {response.StatusCode}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error getting resource metadata: {ex.Message}"); + } + + return null; + } + + /// + public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) + { + try + { + // Use AuthenticationUtils to handle the 401 challenge + var resourceMetadata = await AuthorizationHelpers.ExtractProtectedResourceMetadata( + response, + _serverUrl, + cancellationToken); + + // If we get here, the resource metadata is valid and matches our server + Console.WriteLine($"Successfully validated resource metadata for: {resourceMetadata.Resource}"); + + // Follow the authorization flow as described in the specs + if (resourceMetadata.AuthorizationServers?.Count > 0) + { + // Get the first authorization server + var authServerUrl = resourceMetadata.AuthorizationServers[0]; + Console.WriteLine($"Using authorization server: {authServerUrl}"); + + // Fetch authorization server metadata + var authServerMetadata = await AuthorizationServerUtils.FetchAuthorizationServerMetadataAsync( + authServerUrl, cancellationToken); + + if (authServerMetadata != null) + { + // Perform the OAuth authorization code flow with PKCE + var token = await PerformAuthorizationCodeFlowAsync(authServerMetadata, resourceMetadata, cancellationToken); + + if (token != null) + { + // Store the token in the cache + string resourceKey = resourceMetadata.Resource.ToString(); + _tokenCache[resourceKey] = token; + Console.WriteLine("Successfully obtained a new token"); + return true; + } + } + else + { + Console.WriteLine("Failed to fetch authorization server metadata"); + } + } + + Console.WriteLine("API key is valid, but might not have sufficient permissions."); + return false; + } + catch (InvalidOperationException ex) + { + // Log the specific error about why the challenge handling failed + Console.WriteLine($"Authentication challenge failed: {ex.Message}"); + return false; + } + catch (Exception ex) + { + // Log any unexpected errors + Console.WriteLine($"Unexpected error during authentication challenge: {ex.Message}"); + return false; + } + } + + /// + /// Performs the OAuth authorization code flow with PKCE. + /// + /// The authorization server metadata. + /// The protected resource metadata. + /// A token to cancel the operation. + /// The token information if successful, otherwise null. + private async Task PerformAuthorizationCodeFlowAsync( + AuthorizationServerMetadata authServerMetadata, + ProtectedResourceMetadata resourceMetadata, + CancellationToken cancellationToken) + { + // Generate PKCE code challenge + var codeVerifier = GenerateCodeVerifier(); + var codeChallenge = GenerateCodeChallenge(codeVerifier); + + // Build the authorization URL + var authorizationUrl = BuildAuthorizationUrl(authServerMetadata, codeChallenge, resourceMetadata); + Console.WriteLine($"Authorization URL: {authorizationUrl}"); + + // Start a local HTTP listener to receive the authorization code callback + var authorizationCode = await StartLocalAuthorizationServerAsync(authorizationUrl, _redirectUri, cancellationToken); + + if (string.IsNullOrEmpty(authorizationCode)) + { + Console.WriteLine("Failed to get authorization code from server"); + return null; + } + + Console.WriteLine($"Received authorization code: {authorizationCode[..Math.Min(6, authorizationCode.Length)]}..."); + + // Exchange the authorization code for tokens + return await ExchangeCodeForTokenAsync(authServerMetadata, authorizationCode, codeVerifier, cancellationToken); + } + + /// + /// Starts a local HTTP server to receive the authorization code from the OAuth redirect. + /// + /// The authorization URL to redirect the user to. + /// The redirect URI where the authorization code will be sent. + /// A token to cancel the operation. + /// The authorization code if successful, otherwise null. + private async Task StartLocalAuthorizationServerAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) + { + // Extract the redirect URI path including the query string + var redirectUriWithPath = redirectUri.AbsoluteUri; + + // For the listener prefix, we want just the scheme, host, and port part + var listenerPrefix = redirectUri.GetLeftPart(UriPartial.Authority); + + // Make sure the listener prefix has a trailing slash + if (!listenerPrefix.EndsWith("/")) + { + listenerPrefix += "/"; + } + + Console.WriteLine($"Setting up HTTP listener with prefix: {listenerPrefix}"); + + using var listener = new System.Net.HttpListener(); + listener.Prefixes.Add(listenerPrefix); + + try + { + // Start the listener first + listener.Start(); + Console.WriteLine("HTTP listener started"); + + // Create a cancellation token source with timeout + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); + + // Open the browser to the authorization URL + Console.WriteLine($"Opening browser to: {authorizationUrl}"); + OpenBrowser(authorizationUrl); + + // Race the HTTP callback against the timeout token + var contextTask = listener.GetContextAsync(); + var completedTask = await Task.WhenAny(contextTask, Task.Delay(Timeout.Infinite, linkedCts.Token)); + + if (completedTask != contextTask) + { + Console.WriteLine("Authorization timed out"); + return null; + } + + // Get the completed HTTP context + var context = await contextTask; + + // Process the callback response + return ProcessAuthorizationCallback(context); + } + catch (TaskCanceledException) + { + Console.WriteLine("Authorization was canceled"); + return null; + } + catch (Exception ex) + { + Console.WriteLine($"Error during authorization: {ex.Message}"); + return null; + } + finally + { + // Ensure the listener is stopped + if (listener.IsListening) + { + listener.Stop(); + Console.WriteLine("HTTP listener stopped"); + } + } + } + + /// + /// Process the callback from the authorization server. + /// + /// The HTTP context from the callback. + /// The authorization code if present, otherwise null. + private string? ProcessAuthorizationCallback(System.Net.HttpListenerContext context) + { + try + { + // Parse the query string to get the authorization code + var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty); + var code = query["code"]; + var error = query["error"]; + + // Send a response to the browser + string responseHtml = "

Authentication complete

You can close this window now.

"; + 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(); + + // Check for errors + if (!string.IsNullOrEmpty(error)) + { + Console.WriteLine($"Authorization error: {error}"); + return null; + } + + // Return the authorization code + if (!string.IsNullOrEmpty(code)) + { + Console.WriteLine($"Received authorization code: {code[..Math.Min(6, code.Length)]}..."); + return code; + } + else + { + Console.WriteLine("No authorization code received"); + return null; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error processing callback: {ex.Message}"); + return null; + } + } + + /// + /// Opens the system browser to the specified URL. + /// + /// The URL to open in the browser. + private void OpenBrowser(Uri url) + { + try + { + // Use the default system browser to open the URL + Console.WriteLine($"Opening browser to {url}"); + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = url.ToString(), + UseShellExecute = true + }; + System.Diagnostics.Process.Start(psi); + } + catch (Exception ex) + { + Console.WriteLine($"Error opening browser: {ex.Message}"); + Console.WriteLine($"Please manually browse to: {url}"); + } + } + + /// + /// Builds the authorization URL for the authorization code flow. + /// + private Uri BuildAuthorizationUrl( + AuthorizationServerMetadata authServerMetadata, + string codeChallenge, + ProtectedResourceMetadata resourceMetadata) + { + var queryParams = HttpUtility.ParseQueryString(string.Empty); + queryParams["client_id"] = clientId; + queryParams["redirect_uri"] = _redirectUri.ToString(); + queryParams["response_type"] = "code"; + queryParams["code_challenge"] = codeChallenge; + queryParams["code_challenge_method"] = "S256"; + + // Use the scopes provided in the constructor + if (_scopes.Any()) + { + queryParams["scope"] = string.Join(" ", _scopes); + } + // If no scopes were provided, fall back to the resource metadata scopes + else if (resourceMetadata.ScopesSupported.Count > 0) + { + queryParams["scope"] = string.Join(" ", resourceMetadata.ScopesSupported); + Console.WriteLine("Warning: Using scopes from resource metadata. It's recommended to provide scopes in the constructor instead."); + } + + // Create the authorization URL + var uriBuilder = new UriBuilder(authServerMetadata.AuthorizationEndpoint); + uriBuilder.Query = queryParams.ToString(); + + return uriBuilder.Uri; + } + + /// + /// Exchanges an authorization code for an access token. + /// + private async Task ExchangeCodeForTokenAsync( + AuthorizationServerMetadata authServerMetadata, + string authorizationCode, + string codeVerifier, + CancellationToken cancellationToken) + { + using var httpClient = new HttpClient(); + + // Set up the request to the token endpoint + var requestContent = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = authorizationCode, + ["redirect_uri"] = _redirectUri.ToString(), + ["client_id"] = clientId, + ["code_verifier"] = codeVerifier + }); + + // Add client authentication if we have a client secret + if (!string.IsNullOrEmpty(clientSecret)) + { + var authHeader = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Basic", authHeader); + } + + try + { + // Make the token request + var response = await httpClient.PostAsync( + authServerMetadata.TokenEndpoint, + requestContent, + cancellationToken); + + if (response.IsSuccessStatusCode) + { + // Parse the token response + var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + var tokenResponse = JsonSerializer.Deserialize( + responseJson, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (tokenResponse != null) + { + // Set the time when the token was obtained + tokenResponse.ObtainedAt = DateTimeOffset.UtcNow; + + // Calculate expiration time if not set + if (tokenResponse.ExpiresIn > 0 && tokenResponse.ExpiresAt == default) + { + tokenResponse.ExpiresAt = tokenResponse.ObtainedAt.AddSeconds(tokenResponse.ExpiresIn); + } + + Console.WriteLine("Token exchange successful"); + return tokenResponse; + } + } + else + { + Console.WriteLine($"Token request failed: {response.StatusCode}"); + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + Console.WriteLine($"Error: {errorContent}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Exception during token exchange: {ex.Message}"); + } + + return null; + } + + /// + /// Generates a random code verifier for PKCE. + /// + private string GenerateCodeVerifier() + { + var bytes = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + /// + /// Generates a code challenge from a code verifier using SHA256. + /// + private string GenerateCodeChallenge(string codeVerifier) + { + using var sha256 = SHA256.Create(); + var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); + return Convert.ToBase64String(challengeBytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} \ No newline at end of file diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 5af1420a..46a3adb4 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -13,7 +13,11 @@ static async Task Main(string[] args) var serverUrl = "http://localhost:7071/sse"; - var tokenProvider = new BasicOAuthAuthorizationProvider(new Uri(serverUrl)); + var tokenProvider = new BasicOAuthAuthorizationProvider(new Uri(serverUrl), + clientId: "6ad97b5f-7a7b-413f-8603-7a3517d4adb8", + redirectUri: new Uri("http://localhost:1179/callback"), + scopes: new List { "api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read" }); + var httpClient = new HttpClient().UseMcpAuthorizationProvider(tokenProvider); Console.WriteLine(); diff --git a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs b/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs deleted file mode 100644 index 61add8e5..00000000 --- a/samples/ProtectedMCPClient/SimpleAccessTokenProvider.cs +++ /dev/null @@ -1,268 +0,0 @@ -using ModelContextProtocol.Authentication; -using ModelContextProtocol.Types.Authentication; -using ProtectedMCPClient.Types; -using ProtectedMCPClient.Utils; -using System.Collections.Concurrent; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Web; - -namespace ProtectedMCPClient; - -/// -/// A simple implementation of IAccessTokenProvider that uses a fixed API key. -/// This is just for demonstration purposes. -/// -public partial class BasicOAuthAuthorizationProvider : IMcpAuthorizationProvider -{ - private readonly ConcurrentDictionary _tokenCache = new(); - private readonly Uri _serverUrl; - private readonly string _clientId; - private readonly string _clientSecret; - private readonly Uri _redirectUri; - - public BasicOAuthAuthorizationProvider(Uri serverUrl, string clientId = "demo-client", string clientSecret = "", Uri? redirectUri = null) - { - _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); - _clientId = clientId; - _clientSecret = clientSecret; - _redirectUri = redirectUri ?? new Uri("http://localhost:8080/callback"); - } - - /// - public async Task GetCredentialAsync(Uri resourceUri, CancellationToken cancellationToken = default) - { - Console.WriteLine($"Getting authentication token for {resourceUri}"); - - // Check if we have a valid cached token - string resourceKey = resourceUri.ToString(); - if (_tokenCache.TryGetValue(resourceKey, out var tokenInfo)) - { - Console.WriteLine("Using cached token"); - return tokenInfo.AccessToken; - } - - return string.Empty; - } - - /// - public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) - { - try - { - // Use AuthenticationUtils to handle the 401 challenge - var resourceMetadata = await AuthorizationHelpers.ExtractProtectedResourceMetadata( - response, - _serverUrl, - cancellationToken); - - // If we get here, the resource metadata is valid and matches our server - Console.WriteLine($"Successfully validated resource metadata for: {resourceMetadata.Resource}"); - - // Follow the authorization flow as described in the specs - if (resourceMetadata.AuthorizationServers?.Count > 0) - { - // Get the first authorization server - var authServerUrl = resourceMetadata.AuthorizationServers[0]; - Console.WriteLine($"Using authorization server: {authServerUrl}"); - - // Fetch authorization server metadata - var authServerMetadata = await AuthorizationServerUtils.FetchAuthorizationServerMetadataAsync( - authServerUrl, cancellationToken); - - if (authServerMetadata != null) - { - // Perform the OAuth authorization code flow with PKCE - var token = await PerformAuthorizationCodeFlowAsync(authServerMetadata, resourceMetadata, cancellationToken); - - if (token != null) - { - // Store the token in the cache - string resourceKey = resourceMetadata.Resource.ToString(); - _tokenCache[resourceKey] = token; - Console.WriteLine("Successfully obtained a new token"); - return true; - } - } - else - { - Console.WriteLine("Failed to fetch authorization server metadata"); - } - } - - Console.WriteLine("API key is valid, but might not have sufficient permissions."); - return false; - } - catch (InvalidOperationException ex) - { - // Log the specific error about why the challenge handling failed - Console.WriteLine($"Authentication challenge failed: {ex.Message}"); - return false; - } - catch (Exception ex) - { - // Log any unexpected errors - Console.WriteLine($"Unexpected error during authentication challenge: {ex.Message}"); - return false; - } - } - - /// - /// Performs the OAuth authorization code flow with PKCE. - /// - /// The authorization server metadata. - /// The protected resource metadata. - /// A token to cancel the operation. - /// The token information if successful, otherwise null. - private async Task PerformAuthorizationCodeFlowAsync( - AuthorizationServerMetadata authServerMetadata, - ProtectedResourceMetadata resourceMetadata, - CancellationToken cancellationToken) - { - // Generate PKCE code challenge - var codeVerifier = GenerateCodeVerifier(); - var codeChallenge = GenerateCodeChallenge(codeVerifier); - - // In a real client, you would redirect the user to the authorization endpoint - // For this sample, we'll simulate the authorization code grant - Console.WriteLine("In a real app, the user would be redirected to the authorization URL:"); - - // Build the authorization URL - var authorizationUrl = BuildAuthorizationUrl(authServerMetadata, codeChallenge, resourceMetadata); - Console.WriteLine($"Authorization URL: {authorizationUrl}"); - - // In a real app, you would wait for the redirect with the authorization code - // For this sample, we'll simulate it - Console.WriteLine("Simulating authorization code grant (in a real app, user would interact with the auth server)"); - - // Simulate getting an authorization code (this would come from the redirect in a real app) - // NOTE: This is just for demonstration. In a real client, you'd parse the authorization code from the redirect - string simulatedAuthCode = "simulated_auth_code_would_come_from_redirect"; - - // Exchange the authorization code for tokens - return await ExchangeCodeForTokenAsync(authServerMetadata, simulatedAuthCode, codeVerifier, cancellationToken); - } - - /// - /// Builds the authorization URL for the authorization code flow. - /// - private Uri BuildAuthorizationUrl( - AuthorizationServerMetadata authServerMetadata, - string codeChallenge, - ProtectedResourceMetadata resourceMetadata) - { - var queryParams = HttpUtility.ParseQueryString(string.Empty); - queryParams["client_id"] = _clientId; - queryParams["redirect_uri"] = _redirectUri.ToString(); - queryParams["response_type"] = "code"; - queryParams["code_challenge"] = codeChallenge; - queryParams["code_challenge_method"] = "S256"; - - // Add scopes if available from resource metadata - if (resourceMetadata.ScopesSupported.Count > 0) - { - queryParams["scope"] = string.Join(" ", resourceMetadata.ScopesSupported); - } - - // Create the authorization URL - var uriBuilder = new UriBuilder(authServerMetadata.AuthorizationEndpoint); - uriBuilder.Query = queryParams.ToString(); - - return uriBuilder.Uri; - } - - /// - /// Exchanges an authorization code for an access token. - /// - private async Task ExchangeCodeForTokenAsync( - AuthorizationServerMetadata authServerMetadata, - string authorizationCode, - string codeVerifier, - CancellationToken cancellationToken) - { - using var httpClient = new HttpClient(); - - // Set up the request to the token endpoint - var requestContent = new FormUrlEncodedContent(new Dictionary - { - ["grant_type"] = "authorization_code", - ["code"] = authorizationCode, - ["redirect_uri"] = _redirectUri.ToString(), - ["client_id"] = _clientId, - ["code_verifier"] = codeVerifier - }); - - // Add client authentication if we have a client secret - if (!string.IsNullOrEmpty(_clientSecret)) - { - var authHeader = Convert.ToBase64String( - Encoding.UTF8.GetBytes($"{_clientId}:{_clientSecret}")); - httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Basic", authHeader); - } - - try - { - // Make the token request - var response = await httpClient.PostAsync( - authServerMetadata.TokenEndpoint, - requestContent, - cancellationToken); - - if (response.IsSuccessStatusCode) - { - // Parse the token response - var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); - var tokenResponse = JsonSerializer.Deserialize( - responseJson, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - if (tokenResponse != null) - { - // There was a valid token response - } - } - else - { - Console.WriteLine($"Token request failed: {response.StatusCode}"); - var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); - Console.WriteLine($"Error: {errorContent}"); - } - } - catch (Exception ex) - { - Console.WriteLine($"Exception during token exchange: {ex.Message}"); - } - - return null; - } - - /// - /// Generates a random code verifier for PKCE. - /// - private string GenerateCodeVerifier() - { - var bytes = new byte[32]; - using var rng = RandomNumberGenerator.Create(); - rng.GetBytes(bytes); - return Convert.ToBase64String(bytes) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - } - - /// - /// Generates a code challenge from a code verifier using SHA256. - /// - private string GenerateCodeChallenge(string codeVerifier) - { - using var sha256 = SHA256.Create(); - var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); - return Convert.ToBase64String(challengeBytes) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - } -} \ No newline at end of file diff --git a/samples/ProtectedMCPClient/Types/TokenContainer.cs b/samples/ProtectedMCPClient/Types/TokenContainer.cs index e5934e0a..c89244b8 100644 --- a/samples/ProtectedMCPClient/Types/TokenContainer.cs +++ b/samples/ProtectedMCPClient/Types/TokenContainer.cs @@ -9,4 +9,14 @@ internal class TokenContainer public string? RefreshToken { get; set; } public int ExpiresIn { get; set; } public string TokenType { get; set; } = string.Empty; + + /// + /// Gets or sets the timestamp when the token was obtained. + /// + public DateTimeOffset ObtainedAt { get; set; } + + /// + /// Gets or sets the timestamp when the token expires. + /// + public DateTimeOffset ExpiresAt { get; set; } } From 2f8498103d761cf43c83d4bb359ad3d809f23e85 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 12:58:14 -0700 Subject: [PATCH 074/128] Update token logic --- .../BasicOAuthAuthorizationProvider.cs | 106 ++++++++++++++---- .../Types/TokenContainer.cs | 20 +++- 2 files changed, 101 insertions(+), 25 deletions(-) diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index 7783dd9c..f9e424b1 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -29,7 +29,12 @@ public partial class BasicOAuthAuthorizationProvider( Uri? redirectUri = null, IEnumerable? scopes = null) : IMcpAuthorizationProvider { + // Cache for tokens, keyed by the canonical resource URI from resource metadata private readonly ConcurrentDictionary _tokenCache = new(); + + // Cache for resource metadata, keyed by the request URI + private readonly ConcurrentDictionary _resourceMetadataCache = new(); + private readonly Uri _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); private readonly Uri _redirectUri = redirectUri ?? new Uri("http://localhost:8080/callback"); private readonly IEnumerable _scopes = scopes ?? Array.Empty(); @@ -37,25 +42,35 @@ public partial class BasicOAuthAuthorizationProvider( /// public async Task GetCredentialAsync(Uri resourceUri, CancellationToken cancellationToken = default) { - Console.WriteLine($"Getting authentication token for {resourceUri}"); + Console.WriteLine($"Getting credential for resource URI: {resourceUri}"); + + // First, get the resource metadata to determine the canonical resource URI + var resourceMetadata = await GetCachedResourceMetadataAsync(resourceUri, cancellationToken); + if (resourceMetadata == null) + { + Console.WriteLine("Failed to get resource metadata, cannot authenticate"); + return null; + } + + // Use the canonical resource URI from the metadata as the cache key + string resourceKey = resourceMetadata.Resource.ToString(); + Console.WriteLine($"Using canonical resource key: {resourceKey}"); // Check if we have a valid cached token - string resourceKey = resourceUri.ToString(); if (_tokenCache.TryGetValue(resourceKey, out var tokenInfo)) { // Check if the token is still valid or needs to be refreshed if (tokenInfo.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(5)) // 5-minute buffer { Console.WriteLine("Using cached token"); + // Return just the access token, not the token type + access token return tokenInfo.AccessToken; } else if (!string.IsNullOrEmpty(tokenInfo.RefreshToken)) { Console.WriteLine("Token expired, attempting to refresh"); - // Get the authorization server metadata for the resource - var resourceMetadata = await GetResourceMetadataAsync(resourceUri, cancellationToken); - if (resourceMetadata?.AuthorizationServers?.Count > 0) + if (resourceMetadata.AuthorizationServers?.Count > 0) { var authServerUrl = resourceMetadata.AuthorizationServers[0]; var authServerMetadata = await AuthorizationServerUtils.FetchAuthorizationServerMetadataAsync( @@ -73,6 +88,7 @@ public partial class BasicOAuthAuthorizationProvider( { _tokenCache[resourceKey] = refreshedToken; Console.WriteLine("Token refreshed successfully"); + // Return just the access token, not the token type + access token return refreshedToken.AccessToken; } else @@ -91,11 +107,47 @@ public partial class BasicOAuthAuthorizationProvider( _tokenCache.TryRemove(resourceKey, out _); } - // We don't have a valid token and need to get a new one - Console.WriteLine("No valid token available"); + // We don't have a valid token - let the 401 handler trigger the auth flow + Console.WriteLine("No valid token available for: " + resourceKey); return null; } - + + /// + /// Gets resource metadata, using the cache if available. + /// + /// The URI of the protected resource. + /// A token to cancel the operation. + /// The protected resource metadata. + private async Task GetCachedResourceMetadataAsync(Uri resourceUri, CancellationToken cancellationToken) + { + string requestUriKey = resourceUri.ToString(); + + // Check if we already have cached metadata for this URI + if (_resourceMetadataCache.TryGetValue(requestUriKey, out var cachedMetadata)) + { + Console.WriteLine($"Using cached resource metadata for: {requestUriKey}"); + return cachedMetadata; + } + + // If not in cache, fetch the metadata + var metadata = await GetResourceMetadataAsync(resourceUri, cancellationToken); + if (metadata != null) + { + // Cache the metadata using the request URI as the key + _resourceMetadataCache[requestUriKey] = metadata; + + // Also cache using any alternate forms of the URI that might be used + if (resourceUri.ToString() != metadata.Resource.ToString()) + { + _resourceMetadataCache[metadata.Resource.ToString()] = metadata; + } + + Console.WriteLine($"Cached resource metadata for {requestUriKey} -> {metadata.Resource}"); + } + + return metadata; + } + /// /// Refreshes an OAuth token using the refresh token. /// @@ -145,15 +197,9 @@ public partial class BasicOAuthAuthorizationProvider( if (tokenResponse != null) { - // Set the time when the token was obtained + // Set the time when the token was obtained - ExpiresAt will be calculated automatically tokenResponse.ObtainedAt = DateTimeOffset.UtcNow; - // Calculate expiration time if not set - if (tokenResponse.ExpiresIn > 0 && tokenResponse.ExpiresAt == default) - { - tokenResponse.ExpiresAt = tokenResponse.ObtainedAt.AddSeconds(tokenResponse.ExpiresIn); - } - // Preserve the refresh token if the response doesn't include a new one if (string.IsNullOrEmpty(tokenResponse.RefreshToken)) { @@ -226,6 +272,24 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp _serverUrl, cancellationToken); + if (resourceMetadata == null) + { + Console.WriteLine("Failed to extract resource metadata from response"); + return false; + } + + // Cache the resource metadata for future use + string requestUriKey = response.RequestMessage?.RequestUri?.ToString() ?? string.Empty; + if (!string.IsNullOrEmpty(requestUriKey)) + { + _resourceMetadataCache[requestUriKey] = resourceMetadata; + + // Also cache with the canonical resource URI + _resourceMetadataCache[resourceMetadata.Resource.ToString()] = resourceMetadata; + + Console.WriteLine($"Cached resource metadata for {requestUriKey} -> {resourceMetadata.Resource}"); + } + // If we get here, the resource metadata is valid and matches our server Console.WriteLine($"Successfully validated resource metadata for: {resourceMetadata.Resource}"); @@ -247,9 +311,11 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp if (token != null) { - // Store the token in the cache + // Store the token in the cache using the canonical resource URI from metadata string resourceKey = resourceMetadata.Resource.ToString(); + Console.WriteLine($"Storing token with canonical resource key: {resourceKey}"); _tokenCache[resourceKey] = token; + Console.WriteLine("Successfully obtained a new token"); return true; } @@ -543,15 +609,9 @@ private Uri BuildAuthorizationUrl( if (tokenResponse != null) { - // Set the time when the token was obtained + // Set the time when the token was obtained - ExpiresAt will be calculated automatically tokenResponse.ObtainedAt = DateTimeOffset.UtcNow; - // Calculate expiration time if not set - if (tokenResponse.ExpiresIn > 0 && tokenResponse.ExpiresAt == default) - { - tokenResponse.ExpiresAt = tokenResponse.ObtainedAt.AddSeconds(tokenResponse.ExpiresIn); - } - Console.WriteLine("Token exchange successful"); return tokenResponse; } diff --git a/samples/ProtectedMCPClient/Types/TokenContainer.cs b/samples/ProtectedMCPClient/Types/TokenContainer.cs index c89244b8..bb00b3e7 100644 --- a/samples/ProtectedMCPClient/Types/TokenContainer.cs +++ b/samples/ProtectedMCPClient/Types/TokenContainer.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace ProtectedMCPClient.Types; /// @@ -5,18 +7,32 @@ namespace ProtectedMCPClient.Types; /// internal class TokenContainer { + [JsonPropertyName("access_token")] public string AccessToken { get; set; } = string.Empty; + + [JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; } + + [JsonPropertyName("expires_in")] public int ExpiresIn { get; set; } + + [JsonPropertyName("ext_expires_in")] + public int ExtExpiresIn { get; set; } + + [JsonPropertyName("token_type")] public string TokenType { get; set; } = string.Empty; + [JsonPropertyName("scope")] + public string Scope { get; set; } = string.Empty; + /// /// Gets or sets the timestamp when the token was obtained. /// public DateTimeOffset ObtainedAt { get; set; } /// - /// Gets or sets the timestamp when the token expires. + /// Gets the timestamp when the token expires, calculated from ObtainedAt and ExpiresIn. /// - public DateTimeOffset ExpiresAt { get; set; } + [JsonIgnore] + public DateTimeOffset ExpiresAt => ObtainedAt.AddSeconds(ExpiresIn); } From 6768089e63ac939126dfdf3d717c586112755650 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 13:53:00 -0700 Subject: [PATCH 075/128] Significantly more basic provider implementation --- .../BasicOAuthAuthorizationProvider.cs | 628 +++++------------- samples/ProtectedMCPClient/Program.cs | 15 +- .../Types/TokenContainer.cs | 1 + 3 files changed, 178 insertions(+), 466 deletions(-) diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index f9e424b1..d35d41c9 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -1,8 +1,6 @@ using ModelContextProtocol.Authentication; using ModelContextProtocol.Types.Authentication; using ProtectedMCPClient.Types; -using ProtectedMCPClient.Utils; -using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; @@ -17,152 +15,119 @@ namespace ProtectedMCPClient; /// /// Initializes a new instance of the class. /// -/// The server URL. -/// The OAuth client ID. -/// The OAuth client secret. -/// The OAuth redirect URI. -/// The OAuth scopes required by the application. -public partial class BasicOAuthAuthorizationProvider( +public class BasicOAuthAuthorizationProvider( Uri serverUrl, string clientId = "demo-client", string clientSecret = "", Uri? redirectUri = null, - IEnumerable? scopes = null) : IMcpAuthorizationProvider + IEnumerable? scopes = null, + HttpClient? httpClient = null) : IMcpAuthorizationProvider { - // Cache for tokens, keyed by the canonical resource URI from resource metadata - private readonly ConcurrentDictionary _tokenCache = new(); - - // Cache for resource metadata, keyed by the request URI - private readonly ConcurrentDictionary _resourceMetadataCache = new(); - private readonly Uri _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); private readonly Uri _redirectUri = redirectUri ?? new Uri("http://localhost:8080/callback"); - private readonly IEnumerable _scopes = scopes ?? Array.Empty(); + private readonly List _scopes = scopes?.ToList() ?? new List(); + private readonly HttpClient _httpClient = httpClient ?? new HttpClient(); + + // Single token storage + private TokenContainer? _token; + // Store auth server metadata separately so token only stores token data + private AuthorizationServerMetadata? _authServerMetadata; /// public async Task GetCredentialAsync(Uri resourceUri, CancellationToken cancellationToken = default) { - Console.WriteLine($"Getting credential for resource URI: {resourceUri}"); - - // First, get the resource metadata to determine the canonical resource URI - var resourceMetadata = await GetCachedResourceMetadataAsync(resourceUri, cancellationToken); - if (resourceMetadata == null) + // Return the token if it's valid + if (_token != null && _token.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(5)) { - Console.WriteLine("Failed to get resource metadata, cannot authenticate"); - return null; + return _token.AccessToken; } - // Use the canonical resource URI from the metadata as the cache key - string resourceKey = resourceMetadata.Resource.ToString(); - Console.WriteLine($"Using canonical resource key: {resourceKey}"); - - // Check if we have a valid cached token - if (_tokenCache.TryGetValue(resourceKey, out var tokenInfo)) + // Try to refresh the token if we have a refresh token + if (_token?.RefreshToken != null && _authServerMetadata != null) { - // Check if the token is still valid or needs to be refreshed - if (tokenInfo.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(5)) // 5-minute buffer + var newToken = await RefreshTokenAsync(_token.RefreshToken, _authServerMetadata, cancellationToken); + if (newToken != null) { - Console.WriteLine("Using cached token"); - // Return just the access token, not the token type + access token - return tokenInfo.AccessToken; + _token = newToken; + return _token.AccessToken; } - else if (!string.IsNullOrEmpty(tokenInfo.RefreshToken)) + } + + // No valid token - auth handler will trigger the 401 flow + return null; + } + + /// + public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) + { + try + { + // Get the metadata from the challenge + var resourceMetadata = await AuthorizationHelpers.ExtractProtectedResourceMetadata( + response, _serverUrl, cancellationToken); + + if (resourceMetadata?.AuthorizationServers?.Count > 0) { - Console.WriteLine("Token expired, attempting to refresh"); + // Get auth server metadata + var authServerMetadata = await GetAuthServerMetadataAsync( + resourceMetadata.AuthorizationServers[0], cancellationToken); - if (resourceMetadata.AuthorizationServers?.Count > 0) + if (authServerMetadata != null) { - var authServerUrl = resourceMetadata.AuthorizationServers[0]; - var authServerMetadata = await AuthorizationServerUtils.FetchAuthorizationServerMetadataAsync( - authServerUrl, cancellationToken); - - if (authServerMetadata != null) + // Store auth server metadata for future refresh operations + _authServerMetadata = authServerMetadata; + + // Do the OAuth flow + var token = await DoAuthorizationCodeFlowAsync(authServerMetadata, cancellationToken); + if (token != null) { - // Refresh the token - var refreshedToken = await RefreshTokenAsync( - authServerMetadata, - tokenInfo.RefreshToken, - cancellationToken); - - if (refreshedToken != null) - { - _tokenCache[resourceKey] = refreshedToken; - Console.WriteLine("Token refreshed successfully"); - // Return just the access token, not the token type + access token - return refreshedToken.AccessToken; - } - else - { - Console.WriteLine("Token refresh failed, will need to re-authenticate"); - } + _token = token; + return true; } } } - else - { - Console.WriteLine("Token expired and no refresh token available"); - } - // Remove expired token from cache - _tokenCache.TryRemove(resourceKey, out _); + return false; + } + catch (Exception ex) + { + Console.WriteLine($"Error handling auth challenge: {ex.Message}"); + return false; } - - // We don't have a valid token - let the 401 handler trigger the auth flow - Console.WriteLine("No valid token available for: " + resourceKey); - return null; } - /// - /// Gets resource metadata, using the cache if available. - /// - /// The URI of the protected resource. - /// A token to cancel the operation. - /// The protected resource metadata. - private async Task GetCachedResourceMetadataAsync(Uri resourceUri, CancellationToken cancellationToken) + private async Task GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken) { - string requestUriKey = resourceUri.ToString(); - - // Check if we already have cached metadata for this URI - if (_resourceMetadataCache.TryGetValue(requestUriKey, out var cachedMetadata)) - { - Console.WriteLine($"Using cached resource metadata for: {requestUriKey}"); - return cachedMetadata; - } + // Ensure trailing slash + var baseUrl = authServerUri.ToString(); + if (!baseUrl.EndsWith("/")) baseUrl += "/"; - // If not in cache, fetch the metadata - var metadata = await GetResourceMetadataAsync(resourceUri, cancellationToken); - if (metadata != null) + // Try both well-known endpoints + foreach (var path in new[] { ".well-known/openid-configuration", ".well-known/oauth-authorization-server" }) { - // Cache the metadata using the request URI as the key - _resourceMetadataCache[requestUriKey] = metadata; - - // Also cache using any alternate forms of the URI that might be used - if (resourceUri.ToString() != metadata.Resource.ToString()) + try { - _resourceMetadataCache[metadata.Resource.ToString()] = metadata; + var response = await _httpClient.GetAsync(new Uri(baseUrl + path), cancellationToken); + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var metadata = JsonSerializer.Deserialize( + json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (metadata != null) return metadata; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error fetching auth server metadata from {path}: {ex.Message}"); } - - Console.WriteLine($"Cached resource metadata for {requestUriKey} -> {metadata.Resource}"); } - return metadata; + return null; } - /// - /// Refreshes an OAuth token using the refresh token. - /// - /// The authorization server metadata. - /// The refresh token to use. - /// A token to cancel the operation. - /// The new token information if successful, otherwise null. - private async Task RefreshTokenAsync( - AuthorizationServerMetadata authServerMetadata, - string refreshToken, - CancellationToken cancellationToken) + private async Task RefreshTokenAsync(string refreshToken, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) { - using var httpClient = new HttpClient(); - - // Set up the request to the token endpoint var requestContent = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "refresh_token", @@ -170,37 +135,31 @@ public partial class BasicOAuthAuthorizationProvider( ["client_id"] = clientId }); - // Add client authentication if we have a client secret - if (!string.IsNullOrEmpty(clientSecret)) - { - var authHeader = Convert.ToBase64String( - Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); - httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Basic", authHeader); - } - try { - // Make the token refresh request - var response = await httpClient.PostAsync( - authServerMetadata.TokenEndpoint, - requestContent, - cancellationToken); + using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint) + { + Content = requestContent + }; + // Add client auth if we have a secret + if (!string.IsNullOrEmpty(clientSecret)) + { + var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authValue); + } + + var response = await _httpClient.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode) { - // Parse the token response - var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + var json = await response.Content.ReadAsStringAsync(cancellationToken); var tokenResponse = JsonSerializer.Deserialize( - responseJson, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (tokenResponse != null) { - // Set the time when the token was obtained - ExpiresAt will be calculated automatically + // Set obtained time and preserve refresh token if needed tokenResponse.ObtainedAt = DateTimeOffset.UtcNow; - - // Preserve the refresh token if the response doesn't include a new one if (string.IsNullOrEmpty(tokenResponse.RefreshToken)) { tokenResponse.RefreshToken = refreshToken; @@ -209,262 +168,72 @@ public partial class BasicOAuthAuthorizationProvider( return tokenResponse; } } - else - { - Console.WriteLine($"Token refresh failed: {response.StatusCode}"); - var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); - Console.WriteLine($"Error: {errorContent}"); - } - } - catch (Exception ex) - { - Console.WriteLine($"Exception during token refresh: {ex.Message}"); - } - - return null; - } - - /// - /// Gets the metadata for a protected resource. - /// - /// The URI of the protected resource. - /// A token to cancel the operation. - /// The protected resource metadata. - private async Task GetResourceMetadataAsync(Uri resourceUri, CancellationToken cancellationToken) - { - try - { - using var httpClient = new HttpClient(); - - // Make a HEAD request to the resource to get the WWW-Authenticate header - var request = new HttpRequestMessage(HttpMethod.Head, resourceUri); - var response = await httpClient.SendAsync(request, cancellationToken); - - // Handle 401 Unauthorized response, which should contain the challenge - if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) - { - return await AuthorizationHelpers.ExtractProtectedResourceMetadata( - response, - _serverUrl, - cancellationToken); - } - else - { - Console.WriteLine($"Resource request did not return expected 401 status: {response.StatusCode}"); - } } catch (Exception ex) { - Console.WriteLine($"Error getting resource metadata: {ex.Message}"); + Console.WriteLine($"Error refreshing token: {ex.Message}"); } return null; } - /// - public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) - { - try - { - // Use AuthenticationUtils to handle the 401 challenge - var resourceMetadata = await AuthorizationHelpers.ExtractProtectedResourceMetadata( - response, - _serverUrl, - cancellationToken); - - if (resourceMetadata == null) - { - Console.WriteLine("Failed to extract resource metadata from response"); - return false; - } - - // Cache the resource metadata for future use - string requestUriKey = response.RequestMessage?.RequestUri?.ToString() ?? string.Empty; - if (!string.IsNullOrEmpty(requestUriKey)) - { - _resourceMetadataCache[requestUriKey] = resourceMetadata; - - // Also cache with the canonical resource URI - _resourceMetadataCache[resourceMetadata.Resource.ToString()] = resourceMetadata; - - Console.WriteLine($"Cached resource metadata for {requestUriKey} -> {resourceMetadata.Resource}"); - } - - // If we get here, the resource metadata is valid and matches our server - Console.WriteLine($"Successfully validated resource metadata for: {resourceMetadata.Resource}"); - - // Follow the authorization flow as described in the specs - if (resourceMetadata.AuthorizationServers?.Count > 0) - { - // Get the first authorization server - var authServerUrl = resourceMetadata.AuthorizationServers[0]; - Console.WriteLine($"Using authorization server: {authServerUrl}"); - - // Fetch authorization server metadata - var authServerMetadata = await AuthorizationServerUtils.FetchAuthorizationServerMetadataAsync( - authServerUrl, cancellationToken); - - if (authServerMetadata != null) - { - // Perform the OAuth authorization code flow with PKCE - var token = await PerformAuthorizationCodeFlowAsync(authServerMetadata, resourceMetadata, cancellationToken); - - if (token != null) - { - // Store the token in the cache using the canonical resource URI from metadata - string resourceKey = resourceMetadata.Resource.ToString(); - Console.WriteLine($"Storing token with canonical resource key: {resourceKey}"); - _tokenCache[resourceKey] = token; - - Console.WriteLine("Successfully obtained a new token"); - return true; - } - } - else - { - Console.WriteLine("Failed to fetch authorization server metadata"); - } - } - - Console.WriteLine("API key is valid, but might not have sufficient permissions."); - return false; - } - catch (InvalidOperationException ex) - { - // Log the specific error about why the challenge handling failed - Console.WriteLine($"Authentication challenge failed: {ex.Message}"); - return false; - } - catch (Exception ex) - { - // Log any unexpected errors - Console.WriteLine($"Unexpected error during authentication challenge: {ex.Message}"); - return false; - } - } - - /// - /// Performs the OAuth authorization code flow with PKCE. - /// - /// The authorization server metadata. - /// The protected resource metadata. - /// A token to cancel the operation. - /// The token information if successful, otherwise null. - private async Task PerformAuthorizationCodeFlowAsync( + private async Task DoAuthorizationCodeFlowAsync( AuthorizationServerMetadata authServerMetadata, - ProtectedResourceMetadata resourceMetadata, CancellationToken cancellationToken) { - // Generate PKCE code challenge + // Generate PKCE values var codeVerifier = GenerateCodeVerifier(); var codeChallenge = GenerateCodeChallenge(codeVerifier); - // Build the authorization URL - var authorizationUrl = BuildAuthorizationUrl(authServerMetadata, codeChallenge, resourceMetadata); - Console.WriteLine($"Authorization URL: {authorizationUrl}"); + // Build the auth URL + var authUrl = BuildAuthorizationUrl(authServerMetadata, codeChallenge); - // Start a local HTTP listener to receive the authorization code callback - var authorizationCode = await StartLocalAuthorizationServerAsync(authorizationUrl, _redirectUri, cancellationToken); + // Get auth code + var authCode = await GetAuthorizationCodeAsync(authUrl, cancellationToken); + if (string.IsNullOrEmpty(authCode)) return null; - if (string.IsNullOrEmpty(authorizationCode)) - { - Console.WriteLine("Failed to get authorization code from server"); - return null; - } - - Console.WriteLine($"Received authorization code: {authorizationCode[..Math.Min(6, authorizationCode.Length)]}..."); - - // Exchange the authorization code for tokens - return await ExchangeCodeForTokenAsync(authServerMetadata, authorizationCode, codeVerifier, cancellationToken); + // Exchange for token + return await ExchangeCodeForTokenAsync(authServerMetadata, authCode, codeVerifier, cancellationToken); } - /// - /// Starts a local HTTP server to receive the authorization code from the OAuth redirect. - /// - /// The authorization URL to redirect the user to. - /// The redirect URI where the authorization code will be sent. - /// A token to cancel the operation. - /// The authorization code if successful, otherwise null. - private async Task StartLocalAuthorizationServerAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) + private Uri BuildAuthorizationUrl(AuthorizationServerMetadata authServerMetadata, string codeChallenge) { - // Extract the redirect URI path including the query string - var redirectUriWithPath = redirectUri.AbsoluteUri; - - // For the listener prefix, we want just the scheme, host, and port part - var listenerPrefix = redirectUri.GetLeftPart(UriPartial.Authority); + var queryParams = HttpUtility.ParseQueryString(string.Empty); + queryParams["client_id"] = clientId; + queryParams["redirect_uri"] = _redirectUri.ToString(); + queryParams["response_type"] = "code"; + queryParams["code_challenge"] = codeChallenge; + queryParams["code_challenge_method"] = "S256"; - // Make sure the listener prefix has a trailing slash - if (!listenerPrefix.EndsWith("/")) + if (_scopes.Any()) { - listenerPrefix += "/"; + queryParams["scope"] = string.Join(" ", _scopes); } - Console.WriteLine($"Setting up HTTP listener with prefix: {listenerPrefix}"); + var uriBuilder = new UriBuilder(authServerMetadata.AuthorizationEndpoint); + uriBuilder.Query = queryParams.ToString(); + return uriBuilder.Uri; + } + + private async Task GetAuthorizationCodeAsync(Uri authorizationUrl, CancellationToken cancellationToken) + { + var listenerPrefix = _redirectUri.GetLeftPart(UriPartial.Authority); + if (!listenerPrefix.EndsWith("/")) listenerPrefix += "/"; using var listener = new System.Net.HttpListener(); listener.Prefixes.Add(listenerPrefix); try { - // Start the listener first listener.Start(); - Console.WriteLine("HTTP listener started"); - - // Create a cancellation token source with timeout - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); - // Open the browser to the authorization URL - Console.WriteLine($"Opening browser to: {authorizationUrl}"); + // Open browser to the authorization URL OpenBrowser(authorizationUrl); - // Race the HTTP callback against the timeout token - var contextTask = listener.GetContextAsync(); - var completedTask = await Task.WhenAny(contextTask, Task.Delay(Timeout.Infinite, linkedCts.Token)); - - if (completedTask != contextTask) - { - Console.WriteLine("Authorization timed out"); - return null; - } + // Get the authorization code + var context = await listener.GetContextAsync(); - // Get the completed HTTP context - var context = await contextTask; - - // Process the callback response - return ProcessAuthorizationCallback(context); - } - catch (TaskCanceledException) - { - Console.WriteLine("Authorization was canceled"); - return null; - } - catch (Exception ex) - { - Console.WriteLine($"Error during authorization: {ex.Message}"); - return null; - } - finally - { - // Ensure the listener is stopped - if (listener.IsListening) - { - listener.Stop(); - Console.WriteLine("HTTP listener stopped"); - } - } - } - - /// - /// Process the callback from the authorization server. - /// - /// The HTTP context from the callback. - /// The authorization code if present, otherwise null. - private string? ProcessAuthorizationCallback(System.Net.HttpListenerContext context) - { - try - { - // Parse the query string to get the authorization code + // Parse the response var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty); var code = query["code"]; var error = query["error"]; @@ -477,102 +246,31 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp context.Response.OutputStream.Write(buffer, 0, buffer.Length); context.Response.Close(); - // Check for errors if (!string.IsNullOrEmpty(error)) { - Console.WriteLine($"Authorization error: {error}"); + Console.WriteLine($"Auth error: {error}"); return null; } - // Return the authorization code - if (!string.IsNullOrEmpty(code)) - { - Console.WriteLine($"Received authorization code: {code[..Math.Min(6, code.Length)]}..."); - return code; - } - else - { - Console.WriteLine("No authorization code received"); - return null; - } + return code; } catch (Exception ex) { - Console.WriteLine($"Error processing callback: {ex.Message}"); + Console.WriteLine($"Error getting auth code: {ex.Message}"); return null; } - } - - /// - /// Opens the system browser to the specified URL. - /// - /// The URL to open in the browser. - private void OpenBrowser(Uri url) - { - try - { - // Use the default system browser to open the URL - Console.WriteLine($"Opening browser to {url}"); - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = url.ToString(), - UseShellExecute = true - }; - System.Diagnostics.Process.Start(psi); - } - catch (Exception ex) - { - Console.WriteLine($"Error opening browser: {ex.Message}"); - Console.WriteLine($"Please manually browse to: {url}"); - } - } - - /// - /// Builds the authorization URL for the authorization code flow. - /// - private Uri BuildAuthorizationUrl( - AuthorizationServerMetadata authServerMetadata, - string codeChallenge, - ProtectedResourceMetadata resourceMetadata) - { - var queryParams = HttpUtility.ParseQueryString(string.Empty); - queryParams["client_id"] = clientId; - queryParams["redirect_uri"] = _redirectUri.ToString(); - queryParams["response_type"] = "code"; - queryParams["code_challenge"] = codeChallenge; - queryParams["code_challenge_method"] = "S256"; - - // Use the scopes provided in the constructor - if (_scopes.Any()) - { - queryParams["scope"] = string.Join(" ", _scopes); - } - // If no scopes were provided, fall back to the resource metadata scopes - else if (resourceMetadata.ScopesSupported.Count > 0) + finally { - queryParams["scope"] = string.Join(" ", resourceMetadata.ScopesSupported); - Console.WriteLine("Warning: Using scopes from resource metadata. It's recommended to provide scopes in the constructor instead."); + if (listener.IsListening) listener.Stop(); } - - // Create the authorization URL - var uriBuilder = new UriBuilder(authServerMetadata.AuthorizationEndpoint); - uriBuilder.Query = queryParams.ToString(); - - return uriBuilder.Uri; } - - /// - /// Exchanges an authorization code for an access token. - /// + private async Task ExchangeCodeForTokenAsync( AuthorizationServerMetadata authServerMetadata, string authorizationCode, string codeVerifier, CancellationToken cancellationToken) { - using var httpClient = new HttpClient(); - - // Set up the request to the token endpoint var requestContent = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "authorization_code", @@ -582,45 +280,39 @@ private Uri BuildAuthorizationUrl( ["code_verifier"] = codeVerifier }); - // Add client authentication if we have a client secret - if (!string.IsNullOrEmpty(clientSecret)) - { - var authHeader = Convert.ToBase64String( - Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); - httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Basic", authHeader); - } - try { - // Make the token request - var response = await httpClient.PostAsync( - authServerMetadata.TokenEndpoint, - requestContent, - cancellationToken); + using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint) + { + Content = requestContent + }; + // Add client auth if we have a secret + if (!string.IsNullOrEmpty(clientSecret)) + { + var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authValue); + } + + var response = await _httpClient.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode) { - // Parse the token response - var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + var json = await response.Content.ReadAsStringAsync(cancellationToken); var tokenResponse = JsonSerializer.Deserialize( - responseJson, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (tokenResponse != null) { - // Set the time when the token was obtained - ExpiresAt will be calculated automatically + // Set the time when the token was obtained tokenResponse.ObtainedAt = DateTimeOffset.UtcNow; - - Console.WriteLine("Token exchange successful"); return tokenResponse; } } else { - Console.WriteLine($"Token request failed: {response.StatusCode}"); - var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); - Console.WriteLine($"Error: {errorContent}"); + Console.WriteLine($"Token exchange failed: {response.StatusCode}"); + var error = await response.Content.ReadAsStringAsync(cancellationToken); + Console.WriteLine($"Error: {error}"); } } catch (Exception ex) @@ -630,10 +322,25 @@ private Uri BuildAuthorizationUrl( return null; } - - /// - /// Generates a random code verifier for PKCE. - /// + + private void OpenBrowser(Uri url) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = url.ToString(), + UseShellExecute = true + }; + System.Diagnostics.Process.Start(psi); + } + catch (Exception ex) + { + Console.WriteLine($"Error opening browser: {ex.Message}"); + Console.WriteLine($"Please manually navigate to: {url}"); + } + } + private string GenerateCodeVerifier() { var bytes = new byte[32]; @@ -644,10 +351,7 @@ private string GenerateCodeVerifier() .Replace('+', '-') .Replace('/', '_'); } - - /// - /// Generates a code challenge from a code verifier using SHA256. - /// + private string GenerateCodeChallenge(string codeVerifier) { using var sha256 = SHA256.Create(); diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 46a3adb4..8b91e76a 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -13,12 +13,19 @@ static async Task Main(string[] args) var serverUrl = "http://localhost:7071/sse"; - var tokenProvider = new BasicOAuthAuthorizationProvider(new Uri(serverUrl), + // Create a single HttpClient instance to be shared + var httpClient = new HttpClient(); + + // Pass the HttpClient to the authorization provider + var tokenProvider = new BasicOAuthAuthorizationProvider( + new Uri(serverUrl), clientId: "6ad97b5f-7a7b-413f-8603-7a3517d4adb8", redirectUri: new Uri("http://localhost:1179/callback"), - scopes: new List { "api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read" }); + scopes: new List { "api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read" }, + httpClient: httpClient); - var httpClient = new HttpClient().UseMcpAuthorizationProvider(tokenProvider); + // Use the same HttpClient instance with the authentication provider + var authenticatedClient = httpClient.UseMcpAuthorizationProvider(tokenProvider); Console.WriteLine(); Console.WriteLine($"Connecting to weather server at {serverUrl}..."); @@ -31,7 +38,7 @@ static async Task Main(string[] args) Name = "Secure Weather Client" }; - var transport = new SseClientTransport(transportOptions, httpClient); + var transport = new SseClientTransport(transportOptions, authenticatedClient); var client = await McpClientFactory.CreateAsync(transport); diff --git a/samples/ProtectedMCPClient/Types/TokenContainer.cs b/samples/ProtectedMCPClient/Types/TokenContainer.cs index bb00b3e7..34deb859 100644 --- a/samples/ProtectedMCPClient/Types/TokenContainer.cs +++ b/samples/ProtectedMCPClient/Types/TokenContainer.cs @@ -28,6 +28,7 @@ internal class TokenContainer /// /// Gets or sets the timestamp when the token was obtained. /// + [JsonIgnore] public DateTimeOffset ObtainedAt { get; set; } /// From cbb5c364d0203c702ca24634561b265c524c8760 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 13:57:44 -0700 Subject: [PATCH 076/128] Working client-server interaction --- .../BasicOAuthAuthorizationProvider.cs | 5 ++++- samples/ProtectedMCPClient/Program.cs | 10 +++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index d35d41c9..4550ed07 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -10,7 +10,9 @@ namespace ProtectedMCPClient; /// -/// A simple implementation of an OAuth authorization provider for MCP. +/// A simple implementation of an OAuth authorization provider for MCP. This does not do any token +/// caching or any advanced token protection - it acquires a token and server metadata and holds it +/// in memory as-is. This is NOT PRODUCTION READY and MUST NOT BE USED IN PRODUCTION. /// /// /// Initializes a new instance of the class. @@ -30,6 +32,7 @@ public class BasicOAuthAuthorizationProvider( // Single token storage private TokenContainer? _token; + // Store auth server metadata separately so token only stores token data private AuthorizationServerMetadata? _authServerMetadata; diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 8b91e76a..11b0af59 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -52,10 +52,14 @@ static async Task Main(string[] args) Console.WriteLine($"Found {tools.Count} tools on the server."); Console.WriteLine(); - if (tools.Any(t => t.Name == "protected-data")) + if (tools.Any(t => t.Name == "GetAlerts")) { - Console.WriteLine("Calling protected-data tool..."); - var result = await client.CallToolAsync("protected-data"); + Console.WriteLine("Calling GetAlerts tool..."); + // Update the dictionary to match the expected type IReadOnlyDictionary? + var result = await client.CallToolAsync( + "GetAlerts", + new Dictionary { { "state", "WA" } } + ); Console.WriteLine("Result: " + result.Content[0].Text); Console.WriteLine(); } From 0aeec191da00d293e0892546901c2859262f50a3 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 14:33:22 -0700 Subject: [PATCH 077/128] Cleanup --- samples/ProtectedMCPClient/Program.cs | 4 +-- samples/ProtectedMCPServer/Program.cs | 45 ++++++--------------------- 2 files changed, 11 insertions(+), 38 deletions(-) diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 11b0af59..5a74b6ad 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -13,10 +13,8 @@ static async Task Main(string[] args) var serverUrl = "http://localhost:7071/sse"; - // Create a single HttpClient instance to be shared var httpClient = new HttpClient(); - // Pass the HttpClient to the authorization provider var tokenProvider = new BasicOAuthAuthorizationProvider( new Uri(serverUrl), clientId: "6ad97b5f-7a7b-413f-8603-7a3517d4adb8", @@ -60,6 +58,8 @@ static async Task Main(string[] args) "GetAlerts", new Dictionary { { "state", "WA" } } ); + + //var result = await client.CallToolAsync("GetAuthorizationInfo"); Console.WriteLine("Result: " + result.Content[0].Text); Console.WriteLine(); } diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 740b0a7a..34f000fd 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -1,50 +1,39 @@ +using System.Net.Http.Headers; +using System.Security.Claims; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using ModelContextProtocol.AspNetCore.Authentication; using ModelContextProtocol.Types.Authentication; using ProtectedMCPServer.Tools; -using System.Net.Http.Headers; -using System.Security.Claims; var builder = WebApplication.CreateBuilder(args); -// Define Entra ID (Azure AD) configuration -var tenantId = "a2213e1c-e51e-4304-9a0d-effe57f31655"; // This is the tenant ID from your existing configuration +var serverUrl = "http://localhost:7071/"; +var tenantId = "a2213e1c-e51e-4304-9a0d-effe57f31655"; var instance = "https://login.microsoftonline.com/"; -// Configure authentication to use MCP for challenges and Entra ID JWT Bearer for token validation builder.Services.AddAuthentication(options => { options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme; // Use MCP for challenges + options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { - // Configure for Entra ID (Azure AD) token validation options.Authority = $"{instance}{tenantId}/v2.0"; options.TokenValidationParameters = new TokenValidationParameters { - // Configure validation parameters for Entra ID tokens ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, - - // Default audience - you should replace this with your actual app/API registration ID ValidAudience = "167b4284-3f92-4436-92ed-38b38f83ae08", - - // This validates that tokens come from your Entra ID tenant ValidIssuer = $"{instance}{tenantId}/v2.0", - - // These claims are used by the app for identity representation NameClaimType = "name", RoleClaimType = "roles" }; - // Enable metadata-based issuer key retrieval options.MetadataAddress = $"{instance}{tenantId}/v2.0/.well-known/openid-configuration"; - // Add development mode debug logging for token validation options.Events = new JwtBearerEvents { OnTokenValidated = context => @@ -85,16 +74,13 @@ }; }); -// Add authorization services builder.Services.AddAuthorization(options => { - // Modify the MCP policy to include both MCP and JWT Bearer schemes - // This ensures the bearer token is properly authenticated while maintaining MCP for challenges options.AddMcpPolicy(configurePolicy: builder => builder.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)); }); -// Configure MCP Server +builder.Services.AddHttpContextAccessor(); builder.Services.AddMcpServer() .WithTools() .WithHttpTransport(); @@ -113,21 +99,8 @@ app.MapMcp().RequireAuthorization(McpAuthenticationDefaults.AuthenticationScheme); -Console.WriteLine("Starting MCP server with authorization at http://localhost:7071"); -Console.WriteLine("PRM Document URL: http://localhost:7071/.well-known/oauth-protected-resource"); -Console.WriteLine(" - This endpoint returns different metadata based on the client type!"); -Console.WriteLine(" - Try with different User-Agent headers or add ?mobile query parameter"); - -Console.WriteLine(); -Console.WriteLine("Entra ID (Azure AD) JWT token validation is configured"); -Console.WriteLine(); -Console.WriteLine("To test the server with different client types:"); -Console.WriteLine("1. Standard client: No special headers needed"); -Console.WriteLine("2. Mobile client: Add 'mobile' in User-Agent or use ?mobile query parameter"); -Console.WriteLine("3. Partner client: Include 'partner' in User-Agent or add X-Partner-API header"); -Console.WriteLine(); -Console.WriteLine("Each client type will receive different authorization requirements!"); -Console.WriteLine(); +Console.WriteLine($"Starting MCP server with authorization at {serverUrl}"); +Console.WriteLine($"PRM Document URL: {serverUrl}.well-known/oauth-protected-resource"); Console.WriteLine("Press Ctrl+C to stop the server"); -app.Run("http://localhost:7071/"); +app.Run(serverUrl); From 531370a7e4ad66a3c279139c850b18bb97584d02 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 15:37:48 -0700 Subject: [PATCH 078/128] Simplification --- .../BasicOAuthAuthorizationProvider.cs | 5 ++--- samples/ProtectedMCPClient/Program.cs | 18 +++++++++++------- .../AuthorizationDelegatingHandler.cs | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index 4550ed07..2b53010c 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -22,13 +22,12 @@ public class BasicOAuthAuthorizationProvider( string clientId = "demo-client", string clientSecret = "", Uri? redirectUri = null, - IEnumerable? scopes = null, - HttpClient? httpClient = null) : IMcpAuthorizationProvider + IEnumerable? scopes = null) : IMcpAuthorizationProvider { private readonly Uri _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); private readonly Uri _redirectUri = redirectUri ?? new Uri("http://localhost:8080/callback"); private readonly List _scopes = scopes?.ToList() ?? new List(); - private readonly HttpClient _httpClient = httpClient ?? new HttpClient(); + private readonly HttpClient _httpClient = new HttpClient(); // Single token storage private TokenContainer? _token; diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 5a74b6ad..c8e06e97 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -13,17 +13,20 @@ static async Task Main(string[] args) var serverUrl = "http://localhost:7071/sse"; - var httpClient = new HttpClient(); - var tokenProvider = new BasicOAuthAuthorizationProvider( new Uri(serverUrl), clientId: "6ad97b5f-7a7b-413f-8603-7a3517d4adb8", redirectUri: new Uri("http://localhost:1179/callback"), - scopes: new List { "api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read" }, - httpClient: httpClient); + scopes: new List { "api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read" } + ); + + var authHandler = new AuthorizationDelegatingHandler(tokenProvider, "Bearer") + { + // 3. Set the inner handler (the handler that actually sends the request) + InnerHandler = new HttpClientHandler() + }; - // Use the same HttpClient instance with the authentication provider - var authenticatedClient = httpClient.UseMcpAuthorizationProvider(tokenProvider); + var httpClient = new HttpClient(authHandler); Console.WriteLine(); Console.WriteLine($"Connecting to weather server at {serverUrl}..."); @@ -36,7 +39,8 @@ static async Task Main(string[] args) Name = "Secure Weather Client" }; - var transport = new SseClientTransport(transportOptions, authenticatedClient); + // Use the single, pre-configured HttpClient + var transport = new SseClientTransport(transportOptions, httpClient); var client = await McpClientFactory.CreateAsync(transport); diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index a1fe85f7..7cc6767c 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -5,7 +5,7 @@ namespace ModelContextProtocol.Authentication; /// /// A delegating handler that adds authentication tokens to requests and handles 401 responses. /// -internal class AuthorizationDelegatingHandler : DelegatingHandler +public class AuthorizationDelegatingHandler : DelegatingHandler { private readonly IMcpAuthorizationProvider _tokenProvider; private readonly string _scheme; From d511312df47707f567189d65a975304d4f6f951e Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 15:59:41 -0700 Subject: [PATCH 079/128] Cleanup for consistency --- samples/ProtectedMCPClient/Program.cs | 5 +- .../Authentication/HttpClientExtensions.cs | 67 ------------------- 2 files changed, 1 insertion(+), 71 deletions(-) delete mode 100644 src/ModelContextProtocol/Authentication/HttpClientExtensions.cs diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index c8e06e97..a3a0b2c1 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -22,7 +22,6 @@ static async Task Main(string[] args) var authHandler = new AuthorizationDelegatingHandler(tokenProvider, "Bearer") { - // 3. Set the inner handler (the handler that actually sends the request) InnerHandler = new HttpClientHandler() }; @@ -39,7 +38,6 @@ static async Task Main(string[] args) Name = "Secure Weather Client" }; - // Use the single, pre-configured HttpClient var transport = new SseClientTransport(transportOptions, httpClient); var client = await McpClientFactory.CreateAsync(transport); @@ -57,13 +55,12 @@ static async Task Main(string[] args) if (tools.Any(t => t.Name == "GetAlerts")) { Console.WriteLine("Calling GetAlerts tool..."); - // Update the dictionary to match the expected type IReadOnlyDictionary? + var result = await client.CallToolAsync( "GetAlerts", new Dictionary { { "state", "WA" } } ); - //var result = await client.CallToolAsync("GetAuthorizationInfo"); Console.WriteLine("Result: " + result.Content[0].Text); Console.WriteLine(); } diff --git a/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs b/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs deleted file mode 100644 index cc99fa90..00000000 --- a/src/ModelContextProtocol/Authentication/HttpClientExtensions.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace ModelContextProtocol.Authentication; - -/// -/// Extension methods for related to authentication. -/// -public static class HttpClientExtensions -{ - /// - /// Configures the to use the specified access token provider for authentication. - /// - /// The HTTP client to configure. - /// The token provider that will supply authentication tokens. - /// The authentication scheme to use. Defaults to "Bearer". - /// A new that automatically handles authentication. - /// - /// This extension method configures the HttpClient with a handler that automatically: - /// - /// Adds authentication tokens to outgoing requests - /// Handles 401 Unauthorized responses by attempting to refresh tokens - /// Retries the request with the new token if token refresh is successful - /// - /// - public static HttpClient UseMcpAuthorizationProvider(this HttpClient httpClient, IMcpAuthorizationProvider tokenProvider, string scheme = "Bearer") - { - if (httpClient == null) - throw new ArgumentNullException(nameof(httpClient)); - - if (tokenProvider == null) - throw new ArgumentNullException(nameof(tokenProvider)); - - if (string.IsNullOrWhiteSpace(scheme)) - throw new ArgumentException("Authentication scheme cannot be null or whitespace", nameof(scheme)); - - // Create a new HttpClientHandler with the same settings as the current client - var handler = new HttpClientHandler(); - if (httpClient.DefaultRequestHeaders != null && httpClient.DefaultRequestHeaders.Host != null) - { - // Copy relevant settings from the original client's handler if possible - // This is a simplified approach - some settings might not be accessible - } - - // Create our authentication delegating handler with the token provider - var authHandler = new AuthorizationDelegatingHandler(tokenProvider, scheme) - { - InnerHandler = handler - }; - - // Create a new HttpClient with our delegating handler - var newClient = new HttpClient(authHandler); - - // Copy settings from the original client - newClient.BaseAddress = httpClient.BaseAddress; - newClient.Timeout = httpClient.Timeout; - newClient.MaxResponseContentBufferSize = httpClient.MaxResponseContentBufferSize; - - // Copy headers from original client to new client - if (httpClient.DefaultRequestHeaders != null) - { - foreach (var header in httpClient.DefaultRequestHeaders) - { - newClient.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); - } - } - - return newClient; - } -} \ No newline at end of file From 1496b41b09c03e41924abb1b858ecae7415c7c4a Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 17:10:57 -0700 Subject: [PATCH 080/128] Update with better DevEx --- .../BasicOAuthAuthorizationProvider.cs | 2 ++ samples/ProtectedMCPClient/Program.cs | 11 ++------ .../AuthorizationDelegatingHandler.cs | 17 +++++------- .../Authentication/ITokenProvider.cs | 10 +++++++ .../Protocol/Transport/SseClientTransport.cs | 27 +++++++++++++++++++ .../Transport/SseClientTransportOptions.cs | 2 ++ 6 files changed, 50 insertions(+), 19 deletions(-) diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index 2b53010c..5bfa28aa 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -35,6 +35,8 @@ public class BasicOAuthAuthorizationProvider( // Store auth server metadata separately so token only stores token data private AuthorizationServerMetadata? _authServerMetadata; + public string AuthorizationScheme => "Bearer"; + /// public async Task GetCredentialAsync(Uri resourceUri, CancellationToken cancellationToken = default) { diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index a3a0b2c1..511b7a17 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -18,14 +18,7 @@ static async Task Main(string[] args) clientId: "6ad97b5f-7a7b-413f-8603-7a3517d4adb8", redirectUri: new Uri("http://localhost:1179/callback"), scopes: new List { "api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read" } - ); - - var authHandler = new AuthorizationDelegatingHandler(tokenProvider, "Bearer") - { - InnerHandler = new HttpClientHandler() - }; - - var httpClient = new HttpClient(authHandler); + ); Console.WriteLine(); Console.WriteLine($"Connecting to weather server at {serverUrl}..."); @@ -38,7 +31,7 @@ static async Task Main(string[] args) Name = "Secure Weather Client" }; - var transport = new SseClientTransport(transportOptions, httpClient); + var transport = new SseClientTransport(transportOptions, tokenProvider); var client = await McpClientFactory.CreateAsync(transport); diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index 7cc6767c..1982fa73 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -7,18 +7,15 @@ namespace ModelContextProtocol.Authentication; /// public class AuthorizationDelegatingHandler : DelegatingHandler { - private readonly IMcpAuthorizationProvider _tokenProvider; - private readonly string _scheme; + private readonly IMcpAuthorizationProvider _authorizationProvider; /// /// Initializes a new instance of the class. /// - /// The provider that supplies authentication tokens. - /// The authentication scheme to use, e.g., "Bearer". - public AuthorizationDelegatingHandler(IMcpAuthorizationProvider tokenProvider, string scheme) + /// The provider that supplies authentication tokens. + public AuthorizationDelegatingHandler(IMcpAuthorizationProvider authorizationProvider) { - _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider)); - _scheme = scheme ?? throw new ArgumentNullException(nameof(scheme)); + _authorizationProvider = authorizationProvider ?? throw new ArgumentNullException(nameof(authorizationProvider)); } /// @@ -39,7 +36,7 @@ protected override async Task SendAsync(HttpRequestMessage if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { // Try to handle the unauthorized response - var handled = await _tokenProvider.HandleUnauthorizedResponseAsync( + var handled = await _authorizationProvider.HandleUnauthorizedResponseAsync( response, cancellationToken); @@ -66,10 +63,10 @@ private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, Cance { if (request.RequestUri != null) { - var token = await _tokenProvider.GetCredentialAsync(request.RequestUri, cancellationToken); + var token = await _authorizationProvider.GetCredentialAsync(request.RequestUri, cancellationToken); if (!string.IsNullOrEmpty(token)) { - request.Headers.Authorization = new AuthenticationHeaderValue(_scheme, token); + request.Headers.Authorization = new AuthenticationHeaderValue(_authorizationProvider.AuthorizationScheme, token); } } } diff --git a/src/ModelContextProtocol/Authentication/ITokenProvider.cs b/src/ModelContextProtocol/Authentication/ITokenProvider.cs index 73f5620e..a3d823cc 100644 --- a/src/ModelContextProtocol/Authentication/ITokenProvider.cs +++ b/src/ModelContextProtocol/Authentication/ITokenProvider.cs @@ -6,6 +6,16 @@ namespace ModelContextProtocol.Authentication; /// public interface IMcpAuthorizationProvider { + /// + /// Gets the authentication scheme to use with credentials from this provider. + /// + /// + /// + /// Common values include "Bearer" for JWT tokens and "Basic" for username/password authentication. + /// + /// + string AuthorizationScheme { get; } + /// /// Gets an authentication token or credential for authenticating requests to a resource. /// diff --git a/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs b/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs index 1b286557..cbf1b407 100644 --- a/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs +++ b/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using ModelContextProtocol.Authentication; using ModelContextProtocol.Utils; namespace ModelContextProtocol.Protocol.Transport; @@ -51,6 +52,32 @@ public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient Name = transportOptions.Name ?? transportOptions.Endpoint.ToString(); } + /// + /// Initializes a new instance of the class with authentication support. + /// + /// Configuration options for the transport. + /// The authorization provider to use for authentication. + /// Logger factory for creating loggers used for diagnostic output during transport operations. + public SseClientTransport(SseClientTransportOptions transportOptions, IMcpAuthorizationProvider authorizationProvider, ILoggerFactory? loggerFactory = null) + { + Throw.IfNull(transportOptions); + Throw.IfNull(authorizationProvider); + + _options = transportOptions; + _loggerFactory = loggerFactory; + Name = transportOptions.Name ?? transportOptions.Endpoint.ToString(); + + // Create an auth handler with the authorization provider + var authHandler = new AuthorizationDelegatingHandler(authorizationProvider) + { + InnerHandler = new HttpClientHandler() + }; + + // Create an HttpClient with the auth handler + _httpClient = new HttpClient(authHandler); + _ownsHttpClient = true; + } + /// public string Name { get; } diff --git a/src/ModelContextProtocol/Protocol/Transport/SseClientTransportOptions.cs b/src/ModelContextProtocol/Protocol/Transport/SseClientTransportOptions.cs index b83204ae..e4af6db0 100644 --- a/src/ModelContextProtocol/Protocol/Transport/SseClientTransportOptions.cs +++ b/src/ModelContextProtocol/Protocol/Transport/SseClientTransportOptions.cs @@ -1,5 +1,7 @@ namespace ModelContextProtocol.Protocol.Transport; +using ModelContextProtocol.Authentication; + /// /// Provides options for configuring instances. /// From 6b0b7ef7fb09c13770cd002bdf1b927171625781 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 17:14:17 -0700 Subject: [PATCH 081/128] Cleanup --- .../BasicOAuthAuthorizationProvider.cs | 26 ++++--------------- samples/ProtectedMCPClient/Program.cs | 16 ++---------- 2 files changed, 7 insertions(+), 35 deletions(-) diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index 5bfa28aa..1aa2d4a5 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -26,13 +26,11 @@ public class BasicOAuthAuthorizationProvider( { private readonly Uri _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); private readonly Uri _redirectUri = redirectUri ?? new Uri("http://localhost:8080/callback"); - private readonly List _scopes = scopes?.ToList() ?? new List(); - private readonly HttpClient _httpClient = new HttpClient(); + private readonly List _scopes = scopes?.ToList() ?? []; + private readonly HttpClient _httpClient = new(); - // Single token storage private TokenContainer? _token; - // Store auth server metadata separately so token only stores token data private AuthorizationServerMetadata? _authServerMetadata; public string AuthorizationScheme => "Bearer"; @@ -82,7 +80,7 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp _authServerMetadata = authServerMetadata; // Do the OAuth flow - var token = await DoAuthorizationCodeFlowAsync(authServerMetadata, cancellationToken); + var token = await InitiateAuthorizationCodeFlowAsync(authServerMetadata, cancellationToken); if (token != null) { _token = token; @@ -102,11 +100,9 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp private async Task GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken) { - // Ensure trailing slash var baseUrl = authServerUri.ToString(); if (!baseUrl.EndsWith("/")) baseUrl += "/"; - // Try both well-known endpoints foreach (var path in new[] { ".well-known/openid-configuration", ".well-known/oauth-authorization-server" }) { try @@ -145,8 +141,7 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp { Content = requestContent }; - - // Add client auth if we have a secret + if (!string.IsNullOrEmpty(clientSecret)) { var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); @@ -162,7 +157,6 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp if (tokenResponse != null) { - // Set obtained time and preserve refresh token if needed tokenResponse.ObtainedAt = DateTimeOffset.UtcNow; if (string.IsNullOrEmpty(tokenResponse.RefreshToken)) { @@ -181,22 +175,18 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp return null; } - private async Task DoAuthorizationCodeFlowAsync( + private async Task InitiateAuthorizationCodeFlowAsync( AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) { - // Generate PKCE values var codeVerifier = GenerateCodeVerifier(); var codeChallenge = GenerateCodeChallenge(codeVerifier); - // Build the auth URL var authUrl = BuildAuthorizationUrl(authServerMetadata, codeChallenge); - // Get auth code var authCode = await GetAuthorizationCodeAsync(authUrl, cancellationToken); if (string.IsNullOrEmpty(authCode)) return null; - // Exchange for token return await ExchangeCodeForTokenAsync(authServerMetadata, authCode, codeVerifier, cancellationToken); } @@ -231,18 +221,14 @@ private Uri BuildAuthorizationUrl(AuthorizationServerMetadata authServerMetadata { listener.Start(); - // Open browser to the authorization URL OpenBrowser(authorizationUrl); - // Get the authorization code var context = await listener.GetContextAsync(); - // Parse the response var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty); var code = query["code"]; var error = query["error"]; - // Send a response to the browser string responseHtml = "

Authentication complete

You can close this window now.

"; byte[] buffer = Encoding.UTF8.GetBytes(responseHtml); context.Response.ContentLength64 = buffer.Length; @@ -291,7 +277,6 @@ private Uri BuildAuthorizationUrl(AuthorizationServerMetadata authServerMetadata Content = requestContent }; - // Add client auth if we have a secret if (!string.IsNullOrEmpty(clientSecret)) { var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); @@ -307,7 +292,6 @@ private Uri BuildAuthorizationUrl(AuthorizationServerMetadata authServerMetadata if (tokenResponse != null) { - // Set the time when the token was obtained tokenResponse.ObtainedAt = DateTimeOffset.UtcNow; return tokenResponse; } diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index 511b7a17..a30faa61 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -1,4 +1,3 @@ -using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol.Transport; @@ -17,7 +16,7 @@ static async Task Main(string[] args) new Uri(serverUrl), clientId: "6ad97b5f-7a7b-413f-8603-7a3517d4adb8", redirectUri: new Uri("http://localhost:1179/callback"), - scopes: new List { "api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read" } + scopes: ["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"] ); Console.WriteLine(); @@ -32,7 +31,6 @@ static async Task Main(string[] args) }; var transport = new SseClientTransport(transportOptions, tokenProvider); - var client = await McpClientFactory.CreateAsync(transport); var tools = await client.ListToolsAsync(); @@ -58,15 +56,6 @@ static async Task Main(string[] args) Console.WriteLine(); } } - catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized) - { - // Handle authentication failures specifically - Console.WriteLine("Authentication failed. The server returned a 401 Unauthorized response."); - Console.WriteLine($"Details: {ex.Message}"); - - // Additional handling for 401 - could add manual authentication retry here - Console.WriteLine("You might need to provide a different API key or authentication credentials."); - } catch (Exception ex) { Console.WriteLine($"Error: {ex.Message}"); @@ -74,8 +63,7 @@ static async Task Main(string[] args) { Console.WriteLine($"Inner error: {ex.InnerException.Message}"); } - - // Print stack trace in debug builds + #if DEBUG Console.WriteLine($"Stack trace: {ex.StackTrace}"); #endif From a9acba88fd08266c7b45ea2eeb0c8c55e0c66b6d Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 17:14:55 -0700 Subject: [PATCH 082/128] Delete AuthorizationServerUtils.cs --- .../Utils/AuthorizationServerUtils.cs | 83 ------------------- 1 file changed, 83 deletions(-) delete mode 100644 samples/ProtectedMCPClient/Utils/AuthorizationServerUtils.cs diff --git a/samples/ProtectedMCPClient/Utils/AuthorizationServerUtils.cs b/samples/ProtectedMCPClient/Utils/AuthorizationServerUtils.cs deleted file mode 100644 index eda1cb36..00000000 --- a/samples/ProtectedMCPClient/Utils/AuthorizationServerUtils.cs +++ /dev/null @@ -1,83 +0,0 @@ -using ModelContextProtocol.Types.Authentication; -using System.Text.Json; - -namespace ProtectedMCPClient.Utils -{ - internal class AuthorizationServerUtils - { - /// - /// Fetches the authorization server metadata from a server URL, trying both well-known endpoints. - /// - /// The base URL of the authorization server. - /// A token to cancel the operation. - /// The fetched AuthorizationServerMetadata, or null if it couldn't be fetched. - public static async Task FetchAuthorizationServerMetadataAsync( - Uri authorizationServerUrl, - CancellationToken cancellationToken = default) - { - using var httpClient = new HttpClient(); - - // Make sure the base URL ends with a slash to correctly append well-known paths - string baseUrl = authorizationServerUrl.ToString(); - if (!baseUrl.EndsWith("/")) - { - baseUrl += "/"; - } - - // Try OpenID Connect configuration endpoint first, then OAuth Authorization Server Metadata endpoint - string[] wellKnownPaths = { - ".well-known/openid-configuration", - ".well-known/oauth-authorization-server" - }; - - foreach (var path in wellKnownPaths) - { - // Simply combine the base URL (now with trailing slash) with the path (without leading slash) - var metadataUrl = new Uri(baseUrl + path); - Console.WriteLine($"Trying authorization server metadata endpoint: {metadataUrl}"); - - var metadata = await TryFetchMetadataAsync(httpClient, metadataUrl, cancellationToken); - if (metadata != null) - { - return metadata; - } - } - - return null; - } - - /// - /// Attempts to fetch metadata from a specific URL. - /// - /// The HTTP client to use for the request. - /// The URL to fetch metadata from. - /// A token to cancel the operation. - /// The metadata if successful, or null if the fetch fails. - private static async Task TryFetchMetadataAsync( - HttpClient httpClient, - Uri metadataUrl, - CancellationToken cancellationToken) - { - try - { - var response = await httpClient.GetAsync(metadataUrl, cancellationToken); - if (response.IsSuccessStatusCode) - { - var content = await response.Content.ReadAsStreamAsync(); - return await JsonSerializer.DeserializeAsync(content, - new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - }, - cancellationToken); - } - } - catch (Exception ex) - { - Console.WriteLine($"Error fetching metadata from {metadataUrl}: {ex.Message}"); - } - - return null; - } - } -} From f8650f9f268b80f29160b69bf9007367185b89c7 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 17:19:33 -0700 Subject: [PATCH 083/128] Cleanup --- .../McpAuthorizationPolicyExtensions.cs | 5 ----- .../Authentication/AuthorizationDelegatingHandler.cs | 7 ------- .../Authentication/AuthorizationHelpers.cs | 11 ++--------- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs index 639b1ef3..8c89b716 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs @@ -25,10 +25,8 @@ public static AuthorizationOptions AddMcpPolicy( .RequireAuthenticatedUser() .AddAuthenticationSchemes(McpAuthenticationDefaults.AuthenticationScheme); - // Allow additional configuration if provided configurePolicy?.Invoke(policyBuilder); - // Add the configured policy options.AddPolicy(policyName, policyBuilder.Build()); return options; @@ -53,16 +51,13 @@ public static AuthorizationOptions AddMcpPolicy( return AddMcpPolicy(options, policyName, configurePolicy); } - // Create a policy builder with MCP and additional authentication schemes var allSchemes = new[] { McpAuthenticationDefaults.AuthenticationScheme }.Concat(additionalSchemes).ToArray(); var policyBuilder = new AuthorizationPolicyBuilder(allSchemes) .RequireAuthenticatedUser(); - // Allow additional configuration if provided configurePolicy?.Invoke(policyBuilder); - // Add the configured policy options.AddPolicy(policyName, policyBuilder.Build()); return options; diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index 1982fa73..c0850f6e 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -23,32 +23,25 @@ public AuthorizationDelegatingHandler(IMcpAuthorizationProvider authorizationPro ///
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - // Add the authentication token to the request if not already present if (request.Headers.Authorization == null) { await AddAuthorizationHeaderAsync(request, cancellationToken); } - // Send the request through the inner handler var response = await base.SendAsync(request, cancellationToken); - // Handle unauthorized responses if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { - // Try to handle the unauthorized response var handled = await _authorizationProvider.HandleUnauthorizedResponseAsync( response, cancellationToken); if (handled) { - // If the unauthorized response was handled, retry the request var retryRequest = await CloneHttpRequestMessageAsync(request); - // Get a new token await AddAuthorizationHeaderAsync(retryRequest, cancellationToken); - // Send the retry request return await base.SendAsync(retryRequest, cancellationToken); } } diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index 5f88b98e..c956d096 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -110,16 +110,9 @@ public static async Task ExtractProtectedResourceMeta throw new InvalidOperationException("The WWW-Authenticate header does not contain a resource_metadata parameter"); } - Uri metadataUri = new Uri(resourceMetadataUrl); + Uri metadataUri = new(resourceMetadataUrl); - // Fetch the resource metadata - var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken); - if (metadata == null) - { - throw new InvalidOperationException($"Failed to fetch resource metadata from {resourceMetadataUrl}"); - } - - // Verify the resource matches the server + var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken) ?? throw new InvalidOperationException($"Failed to fetch resource metadata from {resourceMetadataUrl}"); if (!VerifyResourceMatch(metadata, serverUrl)) { throw new InvalidOperationException( From d4cc3addea5f7af7c55e6c165593f48674f705fd Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 18:35:14 -0700 Subject: [PATCH 084/128] Multiple scheme support --- .../BasicOAuthAuthorizationProvider.cs | 68 ++++++---- .../AuthorizationDelegatingHandler.cs | 119 +++++++++++++++--- .../Authentication/ITokenProvider.cs | 32 +++-- .../AuthenticationSchemeMismatchException.cs | 33 +++++ 4 files changed, 204 insertions(+), 48 deletions(-) create mode 100644 src/ModelContextProtocol/Authentication/Types/AuthenticationSchemeMismatchException.cs diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index 1aa2d4a5..bd476697 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -30,38 +30,35 @@ public class BasicOAuthAuthorizationProvider( private readonly HttpClient _httpClient = new(); private TokenContainer? _token; - private AuthorizationServerMetadata? _authServerMetadata; - public string AuthorizationScheme => "Bearer"; + /// + public IEnumerable SupportedSchemes => new[] { "DPoP" }; /// - public async Task GetCredentialAsync(Uri resourceUri, CancellationToken cancellationToken = default) + public Task GetCredentialAsync(string scheme, Uri resourceUri, CancellationToken cancellationToken = default) { - // Return the token if it's valid - if (_token != null && _token.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(5)) + // This provider only supports Bearer tokens + if (scheme != "Bearer") { - return _token.AccessToken; + return Task.FromResult(null); } - - // Try to refresh the token if we have a refresh token - if (_token?.RefreshToken != null && _authServerMetadata != null) - { - var newToken = await RefreshTokenAsync(_token.RefreshToken, _authServerMetadata, cancellationToken); - if (newToken != null) - { - _token = newToken; - return _token.AccessToken; - } - } - - // No valid token - auth handler will trigger the 401 flow - return null; + + return GetBearerTokenAsync(cancellationToken); } /// - public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) + public async Task<(bool Success, string? RecommendedScheme)> HandleUnauthorizedResponseAsync( + HttpResponseMessage response, + string scheme, + CancellationToken cancellationToken = default) { + // This provider only supports Bearer scheme + if (scheme != "Bearer") + { + return (false, null); + } + try { // Get the metadata from the challenge @@ -84,20 +81,43 @@ public async Task HandleUnauthorizedResponseAsync(HttpResponseMessage resp if (token != null) { _token = token; - return true; + return (true, "Bearer"); } } } - return false; + return (false, null); } catch (Exception ex) { Console.WriteLine($"Error handling auth challenge: {ex.Message}"); - return false; + return (false, null); } } + private async Task GetBearerTokenAsync(CancellationToken cancellationToken = default) + { + // Return the token if it's valid + if (_token != null && _token.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(5)) + { + return _token.AccessToken; + } + + // Try to refresh the token if we have a refresh token + if (_token?.RefreshToken != null && _authServerMetadata != null) + { + var newToken = await RefreshTokenAsync(_token.RefreshToken, _authServerMetadata, cancellationToken); + if (newToken != null) + { + _token = newToken; + return _token.AccessToken; + } + } + + // No valid token - auth handler will trigger the 401 flow + return null; + } + private async Task GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken) { var baseUrl = authServerUri.ToString(); diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index c0850f6e..cf537a48 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Authentication.Types; using System.Net.Http.Headers; namespace ModelContextProtocol.Authentication; @@ -8,6 +9,7 @@ namespace ModelContextProtocol.Authentication; public class AuthorizationDelegatingHandler : DelegatingHandler { private readonly IMcpAuthorizationProvider _authorizationProvider; + private string? _currentScheme; /// /// Initializes a new instance of the class. @@ -16,6 +18,13 @@ public class AuthorizationDelegatingHandler : DelegatingHandler public AuthorizationDelegatingHandler(IMcpAuthorizationProvider authorizationProvider) { _authorizationProvider = authorizationProvider ?? throw new ArgumentNullException(nameof(authorizationProvider)); + + // Select first supported scheme as the default + _currentScheme = _authorizationProvider.SupportedSchemes.FirstOrDefault(); + if (_currentScheme == null) + { + throw new ArgumentException("Authorization provider must support at least one authentication scheme.", nameof(authorizationProvider)); + } } /// @@ -23,43 +32,123 @@ public AuthorizationDelegatingHandler(IMcpAuthorizationProvider authorizationPro /// protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - if (request.Headers.Authorization == null) + if (request.Headers.Authorization == null && _currentScheme != null) { - await AddAuthorizationHeaderAsync(request, cancellationToken); + await AddAuthorizationHeaderAsync(request, _currentScheme, cancellationToken); } var response = await base.SendAsync(request, cancellationToken); if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { - var handled = await _authorizationProvider.HandleUnauthorizedResponseAsync( - response, - cancellationToken); - - if (handled) + // Gather the schemes the server wants us to use from WWW-Authenticate headers + var serverSchemes = ExtractServerSupportedSchemes(response); + + // Find the intersection between what the server supports and what our provider supports + var supportedSchemes = _authorizationProvider.SupportedSchemes.ToList(); + string? bestSchemeMatch = null; + + // First try to find a direct match with the current scheme if it's still valid + string schemeUsed = request.Headers.Authorization?.Scheme ?? _currentScheme ?? string.Empty; + if (serverSchemes.Contains(schemeUsed) && supportedSchemes.Contains(schemeUsed)) { - var retryRequest = await CloneHttpRequestMessageAsync(request); - - await AddAuthorizationHeaderAsync(retryRequest, cancellationToken); - - return await base.SendAsync(retryRequest, cancellationToken); + bestSchemeMatch = schemeUsed; + } + else + { + // Try to find any matching scheme between server and provider + bestSchemeMatch = serverSchemes.FirstOrDefault(scheme => supportedSchemes.Contains(scheme)); + + // If still no match, default to the provider's preferred scheme + if (bestSchemeMatch == null && serverSchemes.Count > 0) + { + throw new AuthenticationSchemeMismatchException( + $"No matching authentication scheme found. Server supports: [{string.Join(", ", serverSchemes)}], " + + $"Provider supports: [{string.Join(", ", supportedSchemes)}].", + serverSchemes, + supportedSchemes); + } + else if (bestSchemeMatch == null) + { + // If the server didn't specify any schemes, use the provider's default + bestSchemeMatch = supportedSchemes.FirstOrDefault(); + } + } + + // If we have a scheme to try, use it + if (bestSchemeMatch != null) + { + // Try to handle the 401 response with the selected scheme + var (handled, recommendedScheme) = await _authorizationProvider.HandleUnauthorizedResponseAsync( + response, + bestSchemeMatch, + cancellationToken); + + if (handled) + { + var retryRequest = await CloneHttpRequestMessageAsync(request); + + // Use the recommended scheme if provided, otherwise use our best match + string schemeToUse = recommendedScheme ?? bestSchemeMatch; + if (!string.IsNullOrEmpty(recommendedScheme)) + { + _currentScheme = recommendedScheme; + } + else + { + _currentScheme = bestSchemeMatch; + } + + await AddAuthorizationHeaderAsync(retryRequest, schemeToUse, cancellationToken); + return await base.SendAsync(retryRequest, cancellationToken); + } + else + { + throw new McpException( + $"Failed to handle unauthorized response with scheme '{bestSchemeMatch}'. " + + "The authentication provider was unable to process the authentication challenge."); + } } } return response; } + /// + /// Extracts the authentication schemes that the server supports from the WWW-Authenticate headers. + /// + private static List ExtractServerSupportedSchemes(HttpResponseMessage response) + { + var serverSchemes = new List(); + + if (response.Headers.Contains("WWW-Authenticate")) + { + foreach (var authHeader in response.Headers.GetValues("WWW-Authenticate")) + { + // Extract the scheme from the WWW-Authenticate header + // Format is typically: "Scheme param1=value1, param2=value2" + string scheme = authHeader.Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries)[0]; + if (!string.IsNullOrEmpty(scheme)) + { + serverSchemes.Add(scheme); + } + } + } + + return serverSchemes; + } + /// /// Adds an authorization header to the request. /// - private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, CancellationToken cancellationToken) + private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, string scheme, CancellationToken cancellationToken) { if (request.RequestUri != null) { - var token = await _authorizationProvider.GetCredentialAsync(request.RequestUri, cancellationToken); + var token = await _authorizationProvider.GetCredentialAsync(scheme, request.RequestUri, cancellationToken); if (!string.IsNullOrEmpty(token)) { - request.Headers.Authorization = new AuthenticationHeaderValue(_authorizationProvider.AuthorizationScheme, token); + request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token); } } } diff --git a/src/ModelContextProtocol/Authentication/ITokenProvider.cs b/src/ModelContextProtocol/Authentication/ITokenProvider.cs index a3d823cc..ec2a8435 100644 --- a/src/ModelContextProtocol/Authentication/ITokenProvider.cs +++ b/src/ModelContextProtocol/Authentication/ITokenProvider.cs @@ -7,28 +7,42 @@ namespace ModelContextProtocol.Authentication; public interface IMcpAuthorizationProvider { /// - /// Gets the authentication scheme to use with credentials from this provider. + /// Gets the collection of authentication schemes supported by this provider. /// /// /// - /// Common values include "Bearer" for JWT tokens and "Basic" for username/password authentication. + /// This property returns all authentication schemes that this provider can handle, + /// allowing clients to select the appropriate scheme based on server capabilities. + /// + /// + /// Common values include "Bearer" for JWT tokens, "Basic" for username/password authentication, + /// and "Negotiate" for integrated Windows authentication. /// /// - string AuthorizationScheme { get; } + IEnumerable SupportedSchemes { get; } /// - /// Gets an authentication token or credential for authenticating requests to a resource. + /// Gets an authentication token or credential for authenticating requests to a resource + /// using the specified authentication scheme. /// + /// The authentication scheme to use. /// The URI of the resource requiring authentication. /// A token to cancel the operation. - /// An authentication token string or null if no token could be obtained. - Task GetCredentialAsync(Uri resourceUri, CancellationToken cancellationToken = default); - + /// An authentication token string or null if no token could be obtained for the specified scheme. + Task GetCredentialAsync(string scheme, Uri resourceUri, CancellationToken cancellationToken = default); + /// /// Handles a 401 Unauthorized response from a resource. /// /// The HTTP response that contained the 401 status code. + /// The authentication scheme that was used when the unauthorized response was received. /// A token to cancel the operation. - /// True if the provider was able to handle the unauthorized response, otherwise false. - Task HandleUnauthorizedResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default); + /// + /// A tuple containing a boolean indicating if the provider was able to handle the unauthorized response, + /// and the authentication scheme that should be used for the next attempt. + /// + Task<(bool Success, string? RecommendedScheme)> HandleUnauthorizedResponseAsync( + HttpResponseMessage response, + string scheme, + CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/ModelContextProtocol/Authentication/Types/AuthenticationSchemeMismatchException.cs b/src/ModelContextProtocol/Authentication/Types/AuthenticationSchemeMismatchException.cs new file mode 100644 index 00000000..7272ab90 --- /dev/null +++ b/src/ModelContextProtocol/Authentication/Types/AuthenticationSchemeMismatchException.cs @@ -0,0 +1,33 @@ +namespace ModelContextProtocol.Authentication.Types; + +/// +/// Exception thrown when no compatible authentication scheme can be found between the client and server. +/// +public class AuthenticationSchemeMismatchException : Exception +{ + /// + /// Gets the authentication schemes supported by the server. + /// + public IReadOnlyList ServerSchemes { get; } + + /// + /// Gets the authentication schemes supported by the client provider. + /// + public IReadOnlyList ProviderSchemes { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The exception message. + /// The authentication schemes supported by the server. + /// The authentication schemes supported by the client provider. + public AuthenticationSchemeMismatchException( + string message, + IEnumerable serverSchemes, + IEnumerable providerSchemes) + : base(message) + { + ServerSchemes = serverSchemes.ToList().AsReadOnly(); + ProviderSchemes = providerSchemes.ToList().AsReadOnly(); + } +} From 31f611ac3dfe63d242b86d58788047625ec60772 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Sun, 4 May 2025 18:55:45 -0700 Subject: [PATCH 085/128] Multi-scheme support --- .../BasicOAuthAuthorizationProvider.cs | 2 +- samples/ProtectedMCPServer/Program.cs | 32 ++++++++++++++++ .../McpAuthenticationHandler.cs | 25 +++++++++--- .../McpAuthenticationOptions.cs | 38 ++++++++++++++++++- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index bd476697..6ac9a00b 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -33,7 +33,7 @@ public class BasicOAuthAuthorizationProvider( private AuthorizationServerMetadata? _authServerMetadata; /// - public IEnumerable SupportedSchemes => new[] { "DPoP" }; + public IEnumerable SupportedSchemes => new[] { "Bearer" }; /// public Task GetCredentialAsync(string scheme, Uri resourceUri, CancellationToken cancellationToken = default) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 34f000fd..25a856cc 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -51,6 +51,10 @@ OnChallenge = context => { Console.WriteLine($"Challenging client to authenticate with Entra ID"); + + // Skip the default Bearer header - MCP handler will provide the complete one + context.HandleResponse(); + return Task.CompletedTask; } }; @@ -72,6 +76,34 @@ return metadata; }; + + // Specify authentication schemes that this server supports + options.SupportedAuthenticationSchemes.Add("Bearer"); + options.SupportedAuthenticationSchemes.Add("Basic"); + + // For a server that doesn't want to support Bearer, you would simply not add it: + // options.SupportedAuthenticationSchemes.Add("Basic"); + // options.SupportedAuthenticationSchemes.Add("Digest"); + + // You can also use the dynamic provider for more flexible scheme selection: + /* + options.SupportedAuthenticationSchemesProvider = context => + { + // You can use context information to determine which schemes to offer + var schemes = new List(); + + // Add Bearer for most clients + schemes.Add("Bearer"); + + // Example of conditional scheme based on client type or other factors + if (context.Request.Headers.UserAgent.ToString().Contains("SpecialClient")) + { + schemes.Add("Basic"); + } + + return schemes; + }; + */ }); builder.Services.AddAuthorization(options => diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index 918a9d11..24aa3d06 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -132,15 +132,28 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // Initialize properties if null properties ??= new AuthenticationProperties(); - // Set the WWW-Authenticate header with the resource_metadata - string headerValue = $"Bearer realm=\"{Scheme.Name}\""; - headerValue += $", resource_metadata=\"{rawPrmDocumentUri}\""; - - Response.Headers["WWW-Authenticate"] = headerValue; - // Store the resource_metadata in properties in case other handlers need it properties.Items["resource_metadata"] = rawPrmDocumentUri; + // Get supported schemes from the options + var options = _optionsMonitor.CurrentValue; + var supportedSchemes = options.GetSupportedAuthenticationSchemes(Request.HttpContext).ToList(); + + // If no schemes are explicitly defined, don't add any WWW-Authenticate headers + if (supportedSchemes.Count == 0) + { + return base.HandleChallengeAsync(properties); + } + + // Add headers for each supported authentication scheme + foreach (var scheme in supportedSchemes) + { + // For all schemes, include the realm and resource metadata + // This allows discovery of OAuth capabilities regardless of the authentication scheme + string headerValue = $"{scheme} realm=\"{Scheme.Name}\", resource_metadata=\"{rawPrmDocumentUri}\""; + Response.Headers.Append("WWW-Authenticate", headerValue); + } + return base.HandleChallengeAsync(properties); } } diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs index 7e174959..276d20bd 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs @@ -24,7 +24,8 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions /// The URI to the resource metadata document. /// /// - /// This URI will be included in the WWW-Authenticate header when a 401 response is returned. + /// This URI will be included in the WWW-Authenticate header when a 401 response is returned + /// and Bearer authentication is supported. /// public Uri ResourceMetadataUri { get; set; } = DefaultResourceMetadataUri; @@ -48,6 +49,26 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions /// public Func? ResourceMetadataProvider { get; set; } + /// + /// Gets or sets the authentication schemes supported by this server. + /// + /// + /// When set, these schemes will be included in WWW-Authenticate headers during an authentication challenge. + /// By default, this is empty and must be populated with the authentication schemes your server supports. + /// If Bearer is included, the resource metadata URI will be included in its parameters. + /// + public List SupportedAuthenticationSchemes { get; set; } = new List(); + + /// + /// Gets or sets a delegate that dynamically provides authentication schemes based on the HTTP context. + /// + /// + /// When set, this delegate will be called to determine which authentication schemes to include + /// in WWW-Authenticate headers during an authentication challenge. This takes precedence over the static + /// property. + /// + public Func>? SupportedAuthenticationSchemesProvider { get; set; } + /// /// Gets the resource metadata for the current request. /// @@ -62,4 +83,19 @@ internal ProtectedResourceMetadata GetResourceMetadata(HttpContext context) return ResourceMetadata; } + + /// + /// Gets the supported authentication schemes for the current request. + /// + /// The HTTP context for the current request. + /// The authentication schemes supported for the current request. + internal IEnumerable GetSupportedAuthenticationSchemes(HttpContext context) + { + if (SupportedAuthenticationSchemesProvider != null) + { + return SupportedAuthenticationSchemesProvider(context); + } + + return SupportedAuthenticationSchemes; + } } \ No newline at end of file From 3852be926422ee22cd05072c4a9627eebb6e1912 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Mon, 5 May 2025 13:06:44 -0700 Subject: [PATCH 086/128] Cleaner implementation --- samples/ProtectedMCPServer/Program.cs | 43 ++--------------- .../McpAuthenticationHandler.cs | 37 ++++++--------- .../McpAuthenticationOptions.cs | 46 ++++--------------- 3 files changed, 28 insertions(+), 98 deletions(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 25a856cc..8a358bea 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -14,8 +14,8 @@ builder.Services.AddAuthentication(options => { - options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { @@ -51,10 +51,6 @@ OnChallenge = context => { Console.WriteLine($"Challenging client to authenticate with Entra ID"); - - // Skip the default Bearer header - MCP handler will provide the complete one - context.HandleResponse(); - return Task.CompletedTask; } }; @@ -76,41 +72,9 @@ return metadata; }; - - // Specify authentication schemes that this server supports - options.SupportedAuthenticationSchemes.Add("Bearer"); - options.SupportedAuthenticationSchemes.Add("Basic"); - - // For a server that doesn't want to support Bearer, you would simply not add it: - // options.SupportedAuthenticationSchemes.Add("Basic"); - // options.SupportedAuthenticationSchemes.Add("Digest"); - - // You can also use the dynamic provider for more flexible scheme selection: - /* - options.SupportedAuthenticationSchemesProvider = context => - { - // You can use context information to determine which schemes to offer - var schemes = new List(); - - // Add Bearer for most clients - schemes.Add("Bearer"); - - // Example of conditional scheme based on client type or other factors - if (context.Request.Headers.UserAgent.ToString().Contains("SpecialClient")) - { - schemes.Add("Basic"); - } - - return schemes; - }; - */ }); -builder.Services.AddAuthorization(options => -{ - options.AddMcpPolicy(configurePolicy: builder => - builder.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)); -}); +builder.Services.AddAuthorization(); builder.Services.AddHttpContextAccessor(); builder.Services.AddMcpServer() @@ -129,7 +93,8 @@ app.UseAuthentication(); app.UseAuthorization(); -app.MapMcp().RequireAuthorization(McpAuthenticationDefaults.AuthenticationScheme); +// Use the default MCP policy name that we've configured +app.MapMcp().RequireAuthorization(); Console.WriteLine($"Starting MCP server with authorization at {serverUrl}"); Console.WriteLine($"PRM Document URL: {serverUrl}.well-known/oauth-protected-resource"); diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index 24aa3d06..1f377f63 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -113,11 +113,19 @@ private async Task HandleResourceMetadataRequestAsync() } /// - protected override Task HandleAuthenticateAsync() + protected override async Task HandleAuthenticateAsync() { - // This handler doesn't perform authentication - it only adds resource metadata to challenges - // The actual authentication will be handled by the bearer token handler or other authentication handlers - return Task.FromResult(AuthenticateResult.NoResult()); + // If ForwardAuthenticate is set, forward the authentication to the specified scheme + if (!string.IsNullOrEmpty(Options.ForwardAuthenticate) && + Options.ForwardAuthenticate != Scheme.Name) + { + // Simply forward the authentication request to the specified scheme and return its result + // This ensures we don't interfere with the authentication process + return await Context.AuthenticateAsync(Options.ForwardAuthenticate); + } + + // If no forwarding is configured, this handler doesn't perform authentication + return AuthenticateResult.NoResult(); } /// @@ -135,24 +143,9 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // Store the resource_metadata in properties in case other handlers need it properties.Items["resource_metadata"] = rawPrmDocumentUri; - // Get supported schemes from the options - var options = _optionsMonitor.CurrentValue; - var supportedSchemes = options.GetSupportedAuthenticationSchemes(Request.HttpContext).ToList(); - - // If no schemes are explicitly defined, don't add any WWW-Authenticate headers - if (supportedSchemes.Count == 0) - { - return base.HandleChallengeAsync(properties); - } - - // Add headers for each supported authentication scheme - foreach (var scheme in supportedSchemes) - { - // For all schemes, include the realm and resource metadata - // This allows discovery of OAuth capabilities regardless of the authentication scheme - string headerValue = $"{scheme} realm=\"{Scheme.Name}\", resource_metadata=\"{rawPrmDocumentUri}\""; - Response.Headers.Append("WWW-Authenticate", headerValue); - } + // 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); } diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs index 276d20bd..de8e387a 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs @@ -20,12 +20,19 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions set { base.Events = value; } } + /// + /// Gets or sets the scheme to use for forward authentication. + /// + /// + /// This is currently set as a constant to avoid adding a package dependency. + /// + public new string ForwardAuthenticate { get; set; } = "Bearer"; + /// /// The URI to the resource metadata document. /// /// - /// This URI will be included in the WWW-Authenticate header when a 401 response is returned - /// and Bearer authentication is supported. + /// This URI will be included in the WWW-Authenticate header when a 401 response is returned. /// public Uri ResourceMetadataUri { get; set; } = DefaultResourceMetadataUri; @@ -49,26 +56,6 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions /// public Func? ResourceMetadataProvider { get; set; } - /// - /// Gets or sets the authentication schemes supported by this server. - /// - /// - /// When set, these schemes will be included in WWW-Authenticate headers during an authentication challenge. - /// By default, this is empty and must be populated with the authentication schemes your server supports. - /// If Bearer is included, the resource metadata URI will be included in its parameters. - /// - public List SupportedAuthenticationSchemes { get; set; } = new List(); - - /// - /// Gets or sets a delegate that dynamically provides authentication schemes based on the HTTP context. - /// - /// - /// When set, this delegate will be called to determine which authentication schemes to include - /// in WWW-Authenticate headers during an authentication challenge. This takes precedence over the static - /// property. - /// - public Func>? SupportedAuthenticationSchemesProvider { get; set; } - /// /// Gets the resource metadata for the current request. /// @@ -83,19 +70,4 @@ internal ProtectedResourceMetadata GetResourceMetadata(HttpContext context) return ResourceMetadata; } - - /// - /// Gets the supported authentication schemes for the current request. - /// - /// The HTTP context for the current request. - /// The authentication schemes supported for the current request. - internal IEnumerable GetSupportedAuthenticationSchemes(HttpContext context) - { - if (SupportedAuthenticationSchemesProvider != null) - { - return SupportedAuthenticationSchemesProvider(context); - } - - return SupportedAuthenticationSchemes; - } } \ No newline at end of file From 8f926ba8de596956a70fb5ac783ad23361ceabc4 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Mon, 5 May 2025 13:10:50 -0700 Subject: [PATCH 087/128] Redundant call --- .../Authentication/McpAuthenticationHandler.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index 1f377f63..cbacb4fc 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -131,9 +131,6 @@ protected override async Task HandleAuthenticateAsync() /// protected override Task HandleChallengeAsync(AuthenticationProperties properties) { - // Set the response status code - Response.StatusCode = StatusCodes.Status401Unauthorized; - // Get the absolute URI for the resource metadata string rawPrmDocumentUri = GetAbsoluteResourceMetadataUri(); From c92343cb266bec0e9de6fe65471f4f37c020b087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 12:54:04 -0700 Subject: [PATCH 088/128] Update src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs Co-authored-by: Stephen Toub --- .../Authentication/AuthorizationDelegatingHandler.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index cf537a48..870846d9 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -17,7 +17,9 @@ public class AuthorizationDelegatingHandler : DelegatingHandler /// The provider that supplies authentication tokens. public AuthorizationDelegatingHandler(IMcpAuthorizationProvider authorizationProvider) { - _authorizationProvider = authorizationProvider ?? throw new ArgumentNullException(nameof(authorizationProvider)); + Throw.IfNull(authorizationProvider); + + _authorizationProvider = authorizationProvider; // Select first supported scheme as the default _currentScheme = _authorizationProvider.SupportedSchemes.FirstOrDefault(); From c57b85c1738877e8eb3a967444c086212cb6fd5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 12:54:16 -0700 Subject: [PATCH 089/128] Update src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs Co-authored-by: Stephen Toub --- .../Authentication/AuthorizationDelegatingHandler.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index 870846d9..790fa8b4 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -22,11 +22,8 @@ public AuthorizationDelegatingHandler(IMcpAuthorizationProvider authorizationPro _authorizationProvider = authorizationProvider; // Select first supported scheme as the default - _currentScheme = _authorizationProvider.SupportedSchemes.FirstOrDefault(); - if (_currentScheme == null) - { + _currentScheme = _authorizationProvider.SupportedSchemes.FirstOrDefault() ?? throw new ArgumentException("Authorization provider must support at least one authentication scheme.", nameof(authorizationProvider)); - } } /// From bb25dbd061970df803039868ce328739efd5f546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 12:54:52 -0700 Subject: [PATCH 090/128] Update src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs Co-authored-by: Stephen Toub --- .../Authentication/AuthorizationDelegatingHandler.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index 790fa8b4..fa83014e 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -89,14 +89,7 @@ protected override async Task SendAsync(HttpRequestMessage // Use the recommended scheme if provided, otherwise use our best match string schemeToUse = recommendedScheme ?? bestSchemeMatch; - if (!string.IsNullOrEmpty(recommendedScheme)) - { - _currentScheme = recommendedScheme; - } - else - { - _currentScheme = bestSchemeMatch; - } + _currentScheme = !string.IsNullOrEmpty(recommendedScheme) ? recommendedScheme : bestSchemeMatch; await AddAuthorizationHeaderAsync(retryRequest, schemeToUse, cancellationToken); return await base.SendAsync(retryRequest, cancellationToken); From 291c7ec9de6af698e9ee5d96a9c071107f7c87b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 12:55:04 -0700 Subject: [PATCH 091/128] Update src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs Co-authored-by: Stephen Toub --- .../Authentication/McpAuthenticationHandler.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index cbacb4fc..3b67b573 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -97,10 +97,7 @@ private async Task HandleResourceMetadataRequestAsync() }; // Set default resource if not set - if (metadata.Resource == null) - { - metadata.Resource = new Uri(GetBaseUrl()); - } + metadata.Resource ??= new Uri(GetBaseUrl()); Response.StatusCode = StatusCodes.Status200OK; Response.ContentType = "application/json"; From d72221d7157dd9d3e2c142b6b3537aa008d4a6b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 12:55:15 -0700 Subject: [PATCH 092/128] Update src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs Co-authored-by: Stephen Toub --- .../Authentication/McpAuthorizationPolicyExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs index 8c89b716..c0d6985a 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs @@ -51,7 +51,7 @@ public static AuthorizationOptions AddMcpPolicy( return AddMcpPolicy(options, policyName, configurePolicy); } - var allSchemes = new[] { McpAuthenticationDefaults.AuthenticationScheme }.Concat(additionalSchemes).ToArray(); + string[] allSchemes = [McpAuthenticationDefaults.AuthenticationScheme, ..additionalSchemes]; var policyBuilder = new AuthorizationPolicyBuilder(allSchemes) .RequireAuthenticatedUser(); From 2945083f03d54b7b5099e05d1af30789fae83d9f Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 13:12:25 -0700 Subject: [PATCH 093/128] Clean up scheme checks --- .../AuthorizationDelegatingHandler.cs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index fa83014e..6b404f2c 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Authentication.Types; +using ModelContextProtocol.Utils; using System.Net.Http.Headers; namespace ModelContextProtocol.Authentication; @@ -9,7 +10,7 @@ namespace ModelContextProtocol.Authentication; public class AuthorizationDelegatingHandler : DelegatingHandler { private readonly IMcpAuthorizationProvider _authorizationProvider; - private string? _currentScheme; + private string _currentScheme; /// /// Initializes a new instance of the class. @@ -31,7 +32,7 @@ public AuthorizationDelegatingHandler(IMcpAuthorizationProvider authorizationPro /// protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - if (request.Headers.Authorization == null && _currentScheme != null) + if (request.Headers.Authorization == null) { await AddAuthorizationHeaderAsync(request, _currentScheme, cancellationToken); } @@ -87,11 +88,9 @@ protected override async Task SendAsync(HttpRequestMessage { var retryRequest = await CloneHttpRequestMessageAsync(request); - // Use the recommended scheme if provided, otherwise use our best match - string schemeToUse = recommendedScheme ?? bestSchemeMatch; - _currentScheme = !string.IsNullOrEmpty(recommendedScheme) ? recommendedScheme : bestSchemeMatch; - - await AddAuthorizationHeaderAsync(retryRequest, schemeToUse, cancellationToken); + _currentScheme = recommendedScheme ?? bestSchemeMatch; + + await AddAuthorizationHeaderAsync(retryRequest, _currentScheme, cancellationToken); return await base.SendAsync(retryRequest, cancellationToken); } else From 26f44ded5266a0f8eda07e2dd7199eacd7d5e60d Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 13:16:25 -0700 Subject: [PATCH 094/128] Use ConfigureAwait --- .../AuthorizationDelegatingHandler.cs | 16 ++++++++-------- .../Authentication/AuthorizationHelpers.cs | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index 6b404f2c..bf05ad56 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -34,10 +34,10 @@ protected override async Task SendAsync(HttpRequestMessage { if (request.Headers.Authorization == null) { - await AddAuthorizationHeaderAsync(request, _currentScheme, cancellationToken); + await AddAuthorizationHeaderAsync(request, _currentScheme, cancellationToken).ConfigureAwait(false); } - var response = await base.SendAsync(request, cancellationToken); + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { @@ -82,16 +82,16 @@ protected override async Task SendAsync(HttpRequestMessage var (handled, recommendedScheme) = await _authorizationProvider.HandleUnauthorizedResponseAsync( response, bestSchemeMatch, - cancellationToken); + cancellationToken).ConfigureAwait(false); if (handled) { - var retryRequest = await CloneHttpRequestMessageAsync(request); + var retryRequest = await CloneHttpRequestMessageAsync(request).ConfigureAwait(false); _currentScheme = recommendedScheme ?? bestSchemeMatch; - await AddAuthorizationHeaderAsync(retryRequest, _currentScheme, cancellationToken); - return await base.SendAsync(retryRequest, cancellationToken); + await AddAuthorizationHeaderAsync(retryRequest, _currentScheme, cancellationToken).ConfigureAwait(false); + return await base.SendAsync(retryRequest, cancellationToken).ConfigureAwait(false); } else { @@ -136,7 +136,7 @@ private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, strin { if (request.RequestUri != null) { - var token = await _authorizationProvider.GetCredentialAsync(scheme, request.RequestUri, cancellationToken); + var token = await _authorizationProvider.GetCredentialAsync(scheme, request.RequestUri, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrEmpty(token)) { request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token); @@ -160,7 +160,7 @@ private static async Task CloneHttpRequestMessageAsync(HttpR // Copy the request content if present if (request.Content != null) { - var contentBytes = await request.Content.ReadAsByteArrayAsync(); + var contentBytes = await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false); var cloneContent = new ByteArrayContent(contentBytes); // Copy the content headers diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index c956d096..0f6e7955 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -22,13 +22,13 @@ public static class AuthorizationHelpers using var httpClient = new HttpClient(); try { - var response = await httpClient.GetAsync(metadataUrl, cancellationToken); + var response = await httpClient.GetAsync(metadataUrl, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var content = await response.Content.ReadAsStreamAsync(); + var content = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); return await JsonSerializer.DeserializeAsync(content, McpJsonUtilities.JsonContext.Default.ProtectedResourceMetadata, - cancellationToken); + cancellationToken).ConfigureAwait(false); } catch (Exception) { @@ -112,7 +112,7 @@ public static async Task ExtractProtectedResourceMeta Uri metadataUri = new(resourceMetadataUrl); - var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken) ?? throw new InvalidOperationException($"Failed to fetch resource metadata from {resourceMetadataUrl}"); + var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException($"Failed to fetch resource metadata from {resourceMetadataUrl}"); if (!VerifyResourceMatch(metadata, serverUrl)) { throw new InvalidOperationException( From ca0cdf3991664f2d51d17f65b285d3c2b2c7eeaa Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 13:23:26 -0700 Subject: [PATCH 095/128] Fix LINQ issue. Simplify best scheme match check --- .../AuthorizationDelegatingHandler.cs | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index bf05ad56..e5888f3d 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -56,20 +56,25 @@ protected override async Task SendAsync(HttpRequestMessage } else { - // Try to find any matching scheme between server and provider - bestSchemeMatch = serverSchemes.FirstOrDefault(scheme => supportedSchemes.Contains(scheme)); + // Try to find any matching scheme between server and provider using a HashSet for O(N) time complexity + // Convert the supported schemes to a HashSet for O(1) lookups + var supportedSchemesSet = new HashSet(supportedSchemes, StringComparer.OrdinalIgnoreCase); - // If still no match, default to the provider's preferred scheme - if (bestSchemeMatch == null && serverSchemes.Count > 0) - { - throw new AuthenticationSchemeMismatchException( - $"No matching authentication scheme found. Server supports: [{string.Join(", ", serverSchemes)}], " + - $"Provider supports: [{string.Join(", ", supportedSchemes)}].", - serverSchemes, - supportedSchemes); - } - else if (bestSchemeMatch == null) + // Find the first server scheme that's in our supported set + bestSchemeMatch = serverSchemes.FirstOrDefault(scheme => supportedSchemesSet.Contains(scheme)); + + // If no match was found, either throw an exception or use default + if (bestSchemeMatch is null) { + if (serverSchemes.Count > 0) + { + throw new AuthenticationSchemeMismatchException( + $"No matching authentication scheme found. Server supports: [{string.Join(", ", serverSchemes)}], " + + $"Provider supports: [{string.Join(", ", supportedSchemes)}].", + serverSchemes, + supportedSchemes); + } + // If the server didn't specify any schemes, use the provider's default bestSchemeMatch = supportedSchemes.FirstOrDefault(); } From 45c4b30ed28a2fb95aa7b7da8183585cf42f0161 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 13:37:49 -0700 Subject: [PATCH 096/128] Proper array caching --- .../Authentication/AuthorizationDelegatingHandler.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index e5888f3d..db440a82 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -11,6 +11,7 @@ public class AuthorizationDelegatingHandler : DelegatingHandler { private readonly IMcpAuthorizationProvider _authorizationProvider; private string _currentScheme; + private static readonly char[] SchemeSplitDelimiters = { ' ', ',' }; /// /// Initializes a new instance of the class. @@ -123,7 +124,7 @@ private static List ExtractServerSupportedSchemes(HttpResponseMessage re { // Extract the scheme from the WWW-Authenticate header // Format is typically: "Scheme param1=value1, param2=value2" - string scheme = authHeader.Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries)[0]; + string scheme = authHeader.Split(SchemeSplitDelimiters, StringSplitOptions.RemoveEmptyEntries)[0]; if (!string.IsNullOrEmpty(scheme)) { serverSchemes.Add(scheme); From 627e1e44c62998170a6bf7a49f2fcd0719c85b3a Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 13:52:11 -0700 Subject: [PATCH 097/128] Update 401 handling logic. Remove pragma warning silencing. --- .../AuthorizationDelegatingHandler.cs | 193 +++++++++--------- 1 file changed, 96 insertions(+), 97 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index db440a82..4970433d 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -42,73 +42,114 @@ protected override async Task SendAsync(HttpRequestMessage if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { - // Gather the schemes the server wants us to use from WWW-Authenticate headers - var serverSchemes = ExtractServerSupportedSchemes(response); + return await HandleUnauthorizedResponseAsync(request, response, cancellationToken).ConfigureAwait(false); + } + + return response; + } + + /// + /// Handles a 401 Unauthorized response by attempting to authenticate and retry the request. + /// + private async Task HandleUnauthorizedResponseAsync( + HttpRequestMessage originalRequest, + HttpResponseMessage response, + CancellationToken cancellationToken) + { + // Gather the schemes the server wants us to use from WWW-Authenticate headers + var serverSchemes = ExtractServerSupportedSchemes(response); + + // Find the intersection between what the server supports and what our provider supports + var supportedSchemes = _authorizationProvider.SupportedSchemes.ToList(); + string? bestSchemeMatch = null; + + // First try to find a direct match with the current scheme if it's still valid + string schemeUsed = originalRequest.Headers.Authorization?.Scheme ?? _currentScheme ?? string.Empty; + if (serverSchemes.Contains(schemeUsed) && supportedSchemes.Contains(schemeUsed)) + { + bestSchemeMatch = schemeUsed; + } + else + { + // Try to find any matching scheme between server and provider using a HashSet for O(N) time complexity + // Convert the supported schemes to a HashSet for O(1) lookups + var supportedSchemesSet = new HashSet(supportedSchemes, StringComparer.OrdinalIgnoreCase); - // Find the intersection between what the server supports and what our provider supports - var supportedSchemes = _authorizationProvider.SupportedSchemes.ToList(); - string? bestSchemeMatch = null; + // Find the first server scheme that's in our supported set + bestSchemeMatch = serverSchemes.FirstOrDefault(scheme => supportedSchemesSet.Contains(scheme)); - // First try to find a direct match with the current scheme if it's still valid - string schemeUsed = request.Headers.Authorization?.Scheme ?? _currentScheme ?? string.Empty; - if (serverSchemes.Contains(schemeUsed) && supportedSchemes.Contains(schemeUsed)) - { - bestSchemeMatch = schemeUsed; - } - else + // If no match was found, either throw an exception or use default + if (bestSchemeMatch is null) { - // Try to find any matching scheme between server and provider using a HashSet for O(N) time complexity - // Convert the supported schemes to a HashSet for O(1) lookups - var supportedSchemesSet = new HashSet(supportedSchemes, StringComparer.OrdinalIgnoreCase); - - // Find the first server scheme that's in our supported set - bestSchemeMatch = serverSchemes.FirstOrDefault(scheme => supportedSchemesSet.Contains(scheme)); - - // If no match was found, either throw an exception or use default - if (bestSchemeMatch is null) + if (serverSchemes.Count > 0) { - if (serverSchemes.Count > 0) - { - throw new AuthenticationSchemeMismatchException( - $"No matching authentication scheme found. Server supports: [{string.Join(", ", serverSchemes)}], " + - $"Provider supports: [{string.Join(", ", supportedSchemes)}].", - serverSchemes, - supportedSchemes); - } - - // If the server didn't specify any schemes, use the provider's default - bestSchemeMatch = supportedSchemes.FirstOrDefault(); + throw new AuthenticationSchemeMismatchException( + $"No matching authentication scheme found. Server supports: [{string.Join(", ", serverSchemes)}], " + + $"Provider supports: [{string.Join(", ", supportedSchemes)}].", + serverSchemes, + supportedSchemes); } + + // If the server didn't specify any schemes, use the provider's default + bestSchemeMatch = supportedSchemes.FirstOrDefault(); + } + } + + // If we have a scheme to try, use it + if (bestSchemeMatch != null) + { + // Try to handle the 401 response with the selected scheme + var (handled, recommendedScheme) = await _authorizationProvider.HandleUnauthorizedResponseAsync( + response, + bestSchemeMatch, + cancellationToken).ConfigureAwait(false); + + if (!handled) + { + throw new McpException( + $"Failed to handle unauthorized response with scheme '{bestSchemeMatch}'. " + + "The authentication provider was unable to process the authentication challenge."); } - // If we have a scheme to try, use it - if (bestSchemeMatch != null) + _currentScheme = recommendedScheme ?? bestSchemeMatch; + + var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri) { - // Try to handle the 401 response with the selected scheme - var (handled, recommendedScheme) = await _authorizationProvider.HandleUnauthorizedResponseAsync( - response, - bestSchemeMatch, - cancellationToken).ConfigureAwait(false); - - if (handled) - { - var retryRequest = await CloneHttpRequestMessageAsync(request).ConfigureAwait(false); - - _currentScheme = recommendedScheme ?? bestSchemeMatch; - - await AddAuthorizationHeaderAsync(retryRequest, _currentScheme, cancellationToken).ConfigureAwait(false); - return await base.SendAsync(retryRequest, cancellationToken).ConfigureAwait(false); - } - else + Version = originalRequest.Version, +#if NET + VersionPolicy = originalRequest.VersionPolicy, +#endif + Content = originalRequest.Content + }; + + // Copy headers except Authorization which we'll set separately + foreach (var header in originalRequest.Headers) + { + if (!header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) { - throw new McpException( - $"Failed to handle unauthorized response with scheme '{bestSchemeMatch}'. " + - "The authentication provider was unable to process the authentication challenge."); + retryRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); } } - } +#if NET + foreach (var property in originalRequest.Options) + { + retryRequest.Options.Set(new HttpRequestOptionsKey(property.Key), property.Value); + } +#else + foreach (var property in originalRequest.Properties) + { + retryRequest.Properties.Add(property); + } +#endif - return response; + // Add the new authorization header + await AddAuthorizationHeaderAsync(retryRequest, _currentScheme, cancellationToken).ConfigureAwait(false); + + // Send the retry request + return await base.SendAsync(retryRequest, cancellationToken).ConfigureAwait(false); + } + + return response; // Return the original response if we couldn't handle it } /// @@ -149,46 +190,4 @@ private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, strin } } } - - /// - /// Creates a clone of the HTTP request message. - /// - private static async Task CloneHttpRequestMessageAsync(HttpRequestMessage request) - { - var clone = new HttpRequestMessage(request.Method, request.RequestUri); - - // Copy the request headers - foreach (var header in request.Headers) - { - clone.Headers.TryAddWithoutValidation(header.Key, header.Value); - } - - // Copy the request content if present - if (request.Content != null) - { - var contentBytes = await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false); - var cloneContent = new ByteArrayContent(contentBytes); - - // Copy the content headers - foreach (var header in request.Content.Headers) - { - cloneContent.Headers.TryAddWithoutValidation(header.Key, header.Value); - } - - clone.Content = cloneContent; - } - - // Copy the request properties -#pragma warning disable CS0618 // Type or member is obsolete - foreach (var property in request.Properties) - { - clone.Properties.Add(property); - } -#pragma warning restore CS0618 // Type or member is obsolete - - // Copy the request version - clone.Version = request.Version; - - return clone; - } } \ No newline at end of file From 8903c4219b001ea37d3d4c692ebd2ae574afe6ef Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 14:01:35 -0700 Subject: [PATCH 098/128] Update based on feedback --- .../Authentication/AuthorizationDelegatingHandler.cs | 8 ++------ .../Authentication/AuthorizationHelpers.cs | 3 ++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index 4970433d..fb3d74b2 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -71,13 +71,9 @@ private async Task HandleUnauthorizedResponseAsync( } else { - // Try to find any matching scheme between server and provider using a HashSet for O(N) time complexity - // Convert the supported schemes to a HashSet for O(1) lookups - var supportedSchemesSet = new HashSet(supportedSchemes, StringComparer.OrdinalIgnoreCase); - // Find the first server scheme that's in our supported set - bestSchemeMatch = serverSchemes.FirstOrDefault(scheme => supportedSchemesSet.Contains(scheme)); - + bestSchemeMatch = serverSchemes.Intersect(supportedSchemes, StringComparer.OrdinalIgnoreCase).FirstOrDefault(); + // If no match was found, either throw an exception or use default if (bestSchemeMatch is null) { diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index 0f6e7955..8861cdcf 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -22,7 +22,8 @@ public static class AuthorizationHelpers using var httpClient = new HttpClient(); try { - var response = await httpClient.GetAsync(metadataUrl, cancellationToken).ConfigureAwait(false); + var request = new HttpRequestMessage(HttpMethod.Get, metadataUrl); + var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); From a0275eac581a49c71d6beee9ff34c98b45d8d530 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 15:29:54 -0700 Subject: [PATCH 099/128] Update the HttpClient configuration --- Directory.Packages.props | 10 ++- .../BasicOAuthAuthorizationProvider.cs | 74 +++++++++++++------ samples/ProtectedMCPClient/Program.cs | 29 +++++++- .../ProtectedMCPServer.csproj | 1 - .../AuthorizationDelegatingHandler.cs | 4 +- .../Authentication/AuthorizationHelpers.cs | 29 ++++++-- .../Authentication/ITokenProvider.cs | 2 +- .../ModelContextProtocol.csproj | 1 + .../Protocol/Transport/SseClientTransport.cs | 2 +- 9 files changed, 115 insertions(+), 37 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index bb2a1e32..d5fa5762 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,9 +6,10 @@ - + + @@ -16,16 +17,19 @@ + + - + + - + diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index 6ac9a00b..6bb223b6 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -14,24 +14,54 @@ namespace ProtectedMCPClient; /// caching or any advanced token protection - it acquires a token and server metadata and holds it /// in memory as-is. This is NOT PRODUCTION READY and MUST NOT BE USED IN PRODUCTION. /// -/// -/// Initializes a new instance of the class. -/// -public class BasicOAuthAuthorizationProvider( - Uri serverUrl, - string clientId = "demo-client", - string clientSecret = "", - Uri? redirectUri = null, - IEnumerable? scopes = null) : IMcpAuthorizationProvider +public class BasicOAuthAuthorizationProvider : ITokenProvider { - private readonly Uri _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); - private readonly Uri _redirectUri = redirectUri ?? new Uri("http://localhost:8080/callback"); - private readonly List _scopes = scopes?.ToList() ?? []; - private readonly HttpClient _httpClient = new(); + private readonly Uri _serverUrl; + private readonly Uri _redirectUri; + private readonly List _scopes; + private readonly string _clientId; + private readonly string _clientSecret; + private readonly HttpClient _httpClient; + private readonly AuthorizationHelpers _authorizationHelpers; + + // Client name for IHttpClientFactory used by the BasicOAuthAuthorizationProvider + public const string HttpClientName = "ProtectedMCPClient.OAuth"; private TokenContainer? _token; private AuthorizationServerMetadata? _authServerMetadata; + /// + /// Initializes a new instance of the class. + /// + /// The MCP server URL. + /// The HTTP client factory to use for creating HTTP clients. + /// The authorization helpers. + /// OAuth client ID. + /// OAuth client secret. + /// OAuth redirect URI. + /// OAuth scopes. + public BasicOAuthAuthorizationProvider( + Uri serverUrl, + IHttpClientFactory httpClientFactory, + AuthorizationHelpers authorizationHelpers, + string clientId = "demo-client", + string clientSecret = "", + Uri? redirectUri = null, + IEnumerable? scopes = null) + { + _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); + if (httpClientFactory == null) throw new ArgumentNullException(nameof(httpClientFactory)); + _authorizationHelpers = authorizationHelpers ?? throw new ArgumentNullException(nameof(authorizationHelpers)); + + // Get the HttpClient once during construction instead of for each request + _httpClient = httpClientFactory.CreateClient(HttpClientName); + + _redirectUri = redirectUri ?? new Uri("http://localhost:8080/callback"); + _scopes = scopes?.ToList() ?? []; + _clientId = clientId; + _clientSecret = clientSecret; + } + /// public IEnumerable SupportedSchemes => new[] { "Bearer" }; @@ -61,8 +91,8 @@ public class BasicOAuthAuthorizationProvider( try { - // Get the metadata from the challenge - var resourceMetadata = await AuthorizationHelpers.ExtractProtectedResourceMetadata( + // Get the metadata from the challenge using the instance-based AuthorizationHelpers + var resourceMetadata = await _authorizationHelpers.ExtractProtectedResourceMetadata( response, _serverUrl, cancellationToken); if (resourceMetadata?.AuthorizationServers?.Count > 0) @@ -152,7 +182,7 @@ public class BasicOAuthAuthorizationProvider( { ["grant_type"] = "refresh_token", ["refresh_token"] = refreshToken, - ["client_id"] = clientId + ["client_id"] = _clientId }); try @@ -162,9 +192,9 @@ public class BasicOAuthAuthorizationProvider( Content = requestContent }; - if (!string.IsNullOrEmpty(clientSecret)) + if (!string.IsNullOrEmpty(_clientSecret)) { - var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); + var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_clientId}:{_clientSecret}")); request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authValue); } @@ -213,7 +243,7 @@ public class BasicOAuthAuthorizationProvider( private Uri BuildAuthorizationUrl(AuthorizationServerMetadata authServerMetadata, string codeChallenge) { var queryParams = HttpUtility.ParseQueryString(string.Empty); - queryParams["client_id"] = clientId; + queryParams["client_id"] = _clientId; queryParams["redirect_uri"] = _redirectUri.ToString(); queryParams["response_type"] = "code"; queryParams["code_challenge"] = codeChallenge; @@ -286,7 +316,7 @@ private Uri BuildAuthorizationUrl(AuthorizationServerMetadata authServerMetadata ["grant_type"] = "authorization_code", ["code"] = authorizationCode, ["redirect_uri"] = _redirectUri.ToString(), - ["client_id"] = clientId, + ["client_id"] = _clientId, ["code_verifier"] = codeVerifier }); @@ -297,9 +327,9 @@ private Uri BuildAuthorizationUrl(AuthorizationServerMetadata authServerMetadata Content = requestContent }; - if (!string.IsNullOrEmpty(clientSecret)) + if (!string.IsNullOrEmpty(_clientSecret)) { - var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); + var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_clientId}:{_clientSecret}")); request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authValue); } diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index a30faa61..e8be47f8 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol.Transport; @@ -12,8 +14,33 @@ static async Task Main(string[] args) var serverUrl = "http://localhost:7071/sse"; + var services = new ServiceCollection(); + services.AddHttpClient(); + + var sharedHandler = new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1) + }; + + services.AddHttpClient(BasicOAuthAuthorizationProvider.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => sharedHandler); + + services.AddHttpClient(AuthorizationHelpers.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => sharedHandler); + + services.AddTransient(); + + var serviceProvider = services.BuildServiceProvider(); + + var httpClientFactory = serviceProvider.GetRequiredService(); + var authorizationHelpers = serviceProvider.GetRequiredService(); + + // Create the token provider with proper dependencies var tokenProvider = new BasicOAuthAuthorizationProvider( - new Uri(serverUrl), + new Uri(serverUrl), + httpClientFactory, + authorizationHelpers, clientId: "6ad97b5f-7a7b-413f-8603-7a3517d4adb8", redirectUri: new Uri("http://localhost:1179/callback"), scopes: ["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"] diff --git a/samples/ProtectedMCPServer/ProtectedMCPServer.csproj b/samples/ProtectedMCPServer/ProtectedMCPServer.csproj index df2a4d81..e3d680fd 100644 --- a/samples/ProtectedMCPServer/ProtectedMCPServer.csproj +++ b/samples/ProtectedMCPServer/ProtectedMCPServer.csproj @@ -9,7 +9,6 @@ - \ No newline at end of file diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index fb3d74b2..aac09ecd 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -9,7 +9,7 @@ namespace ModelContextProtocol.Authentication; /// public class AuthorizationDelegatingHandler : DelegatingHandler { - private readonly IMcpAuthorizationProvider _authorizationProvider; + private readonly ITokenProvider _authorizationProvider; private string _currentScheme; private static readonly char[] SchemeSplitDelimiters = { ' ', ',' }; @@ -17,7 +17,7 @@ public class AuthorizationDelegatingHandler : DelegatingHandler /// Initializes a new instance of the class. /// /// The provider that supplies authentication tokens. - public AuthorizationDelegatingHandler(IMcpAuthorizationProvider authorizationProvider) + public AuthorizationDelegatingHandler(ITokenProvider authorizationProvider) { Throw.IfNull(authorizationProvider); diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index 8861cdcf..75a096d9 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Types.Authentication; +using ModelContextProtocol.Utils; using ModelContextProtocol.Utils.Json; using System.Text.Json; @@ -7,23 +8,39 @@ namespace ModelContextProtocol.Authentication; /// /// Provides utility methods for handling authentication in MCP clients. /// -public static class AuthorizationHelpers +public class AuthorizationHelpers { + private readonly HttpClient _httpClient; + + /// + /// Client name for IHttpClientFactory used by the AuthorizationHelpers. + /// + public const string HttpClientName = "ModelContextProtocol.Authentication"; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client factory to use for creating HTTP clients. + public AuthorizationHelpers(IHttpClientFactory httpClientFactory) + { + Throw.IfNull(httpClientFactory); + _httpClient = httpClientFactory.CreateClient(HttpClientName); + } + /// /// Fetches the protected resource metadata from the provided URL. /// /// The URL to fetch the metadata from. /// A token to cancel the operation. /// The fetched ProtectedResourceMetadata, or null if it couldn't be fetched. - private static async Task FetchProtectedResourceMetadataAsync( + private async Task FetchProtectedResourceMetadataAsync( Uri metadataUrl, CancellationToken cancellationToken = default) { - using var httpClient = new HttpClient(); try { var request = new HttpRequestMessage(HttpMethod.Get, metadataUrl); - var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); @@ -69,7 +86,7 @@ private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResou /// The resource metadata if the resource matches the server, otherwise throws an exception. /// Thrown when the response is not a 401, lacks a WWW-Authenticate header, /// lacks a resource_metadata parameter, the metadata can't be fetched, or the resource URI doesn't match the server URL. - public static async Task ExtractProtectedResourceMetadata( + public async Task ExtractProtectedResourceMetadata( HttpResponseMessage response, Uri serverUrl, CancellationToken cancellationToken = default) @@ -154,7 +171,7 @@ public static async Task ExtractProtectedResourceMeta return new KeyValuePair(key, value); }) .Where(kvp => !string.IsNullOrEmpty(kvp.Key)) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + .ToDictionary(); if (paramDict.TryGetValue(parameterName, out var value)) { diff --git a/src/ModelContextProtocol/Authentication/ITokenProvider.cs b/src/ModelContextProtocol/Authentication/ITokenProvider.cs index ec2a8435..869ea0b2 100644 --- a/src/ModelContextProtocol/Authentication/ITokenProvider.cs +++ b/src/ModelContextProtocol/Authentication/ITokenProvider.cs @@ -4,7 +4,7 @@ namespace ModelContextProtocol.Authentication; /// Defines an interface for providing authentication for requests. /// This is the main extensibility point for authentication in MCP clients. ///
-public interface IMcpAuthorizationProvider +public interface ITokenProvider { /// /// Gets the collection of authentication schemes supported by this provider. diff --git a/src/ModelContextProtocol/ModelContextProtocol.csproj b/src/ModelContextProtocol/ModelContextProtocol.csproj index 6de2d30f..500e3503 100644 --- a/src/ModelContextProtocol/ModelContextProtocol.csproj +++ b/src/ModelContextProtocol/ModelContextProtocol.csproj @@ -36,6 +36,7 @@ + diff --git a/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs b/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs index cbf1b407..689d82b1 100644 --- a/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs +++ b/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs @@ -58,7 +58,7 @@ public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient /// Configuration options for the transport. /// The authorization provider to use for authentication. /// Logger factory for creating loggers used for diagnostic output during transport operations. - public SseClientTransport(SseClientTransportOptions transportOptions, IMcpAuthorizationProvider authorizationProvider, ILoggerFactory? loggerFactory = null) + public SseClientTransport(SseClientTransportOptions transportOptions, ITokenProvider authorizationProvider, ILoggerFactory? loggerFactory = null) { Throw.IfNull(transportOptions); Throw.IfNull(authorizationProvider); From 05e3550de3a5f2a991132ef965c3e3e0e27099c4 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 15:38:19 -0700 Subject: [PATCH 100/128] Update for cleanup --- samples/ProtectedMCPServer/Program.cs | 6 ++-- .../McpAuthenticationHandler.cs | 2 +- .../McpAuthenticationOptions.cs | 2 +- .../AuthorizationDelegatingHandler.cs | 7 ++-- .../Authentication/AuthorizationHelpers.cs | 1 - .../{Types => }/ProtectedResourceMetadata.cs | 2 +- .../AuthenticationSchemeMismatchException.cs | 33 ------------------- .../Utils/Json/McpJsonUtilities.cs | 2 +- 8 files changed, 9 insertions(+), 46 deletions(-) rename src/ModelContextProtocol/Authentication/{Types => }/ProtectedResourceMetadata.cs (99%) delete mode 100644 src/ModelContextProtocol/Authentication/Types/AuthenticationSchemeMismatchException.cs diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 8a358bea..076b51bc 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -1,10 +1,10 @@ -using System.Net.Http.Headers; -using System.Security.Claims; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using ModelContextProtocol.AspNetCore.Authentication; -using ModelContextProtocol.Types.Authentication; +using ModelContextProtocol.Authentication; using ProtectedMCPServer.Tools; +using System.Net.Http.Headers; +using System.Security.Claims; var builder = WebApplication.CreateBuilder(args); diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index 3b67b573..0221915c 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using ModelContextProtocol.Types.Authentication; +using ModelContextProtocol.Authentication; using ModelContextProtocol.Utils.Json; using System.Text.Encodings.Web; diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs index de8e387a..edd7223c 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; -using ModelContextProtocol.Types.Authentication; +using ModelContextProtocol.Authentication; namespace ModelContextProtocol.AspNetCore.Authentication; diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index aac09ecd..669d00c6 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -1,4 +1,3 @@ -using ModelContextProtocol.Authentication.Types; using ModelContextProtocol.Utils; using System.Net.Http.Headers; @@ -79,11 +78,9 @@ private async Task HandleUnauthorizedResponseAsync( { if (serverSchemes.Count > 0) { - throw new AuthenticationSchemeMismatchException( + throw new InvalidOperationException( $"No matching authentication scheme found. Server supports: [{string.Join(", ", serverSchemes)}], " + - $"Provider supports: [{string.Join(", ", supportedSchemes)}].", - serverSchemes, - supportedSchemes); + $"Provider supports: [{string.Join(", ", supportedSchemes)}]."); } // If the server didn't specify any schemes, use the provider's default diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index 75a096d9..0e50c08f 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -1,4 +1,3 @@ -using ModelContextProtocol.Types.Authentication; using ModelContextProtocol.Utils; using ModelContextProtocol.Utils.Json; using System.Text.Json; diff --git a/src/ModelContextProtocol/Authentication/Types/ProtectedResourceMetadata.cs b/src/ModelContextProtocol/Authentication/ProtectedResourceMetadata.cs similarity index 99% rename from src/ModelContextProtocol/Authentication/Types/ProtectedResourceMetadata.cs rename to src/ModelContextProtocol/Authentication/ProtectedResourceMetadata.cs index d429ab58..d9d160bd 100644 --- a/src/ModelContextProtocol/Authentication/Types/ProtectedResourceMetadata.cs +++ b/src/ModelContextProtocol/Authentication/ProtectedResourceMetadata.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ModelContextProtocol.Types.Authentication; +namespace ModelContextProtocol.Authentication; /// /// Represents the resource metadata for OAuth authorization as defined in RFC 9396. diff --git a/src/ModelContextProtocol/Authentication/Types/AuthenticationSchemeMismatchException.cs b/src/ModelContextProtocol/Authentication/Types/AuthenticationSchemeMismatchException.cs deleted file mode 100644 index 7272ab90..00000000 --- a/src/ModelContextProtocol/Authentication/Types/AuthenticationSchemeMismatchException.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace ModelContextProtocol.Authentication.Types; - -/// -/// Exception thrown when no compatible authentication scheme can be found between the client and server. -/// -public class AuthenticationSchemeMismatchException : Exception -{ - /// - /// Gets the authentication schemes supported by the server. - /// - public IReadOnlyList ServerSchemes { get; } - - /// - /// Gets the authentication schemes supported by the client provider. - /// - public IReadOnlyList ProviderSchemes { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The exception message. - /// The authentication schemes supported by the server. - /// The authentication schemes supported by the client provider. - public AuthenticationSchemeMismatchException( - string message, - IEnumerable serverSchemes, - IEnumerable providerSchemes) - : base(message) - { - ServerSchemes = serverSchemes.ToList().AsReadOnly(); - ProviderSchemes = providerSchemes.ToList().AsReadOnly(); - } -} diff --git a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs index 010e0332..08a771aa 100644 --- a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs +++ b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs @@ -1,5 +1,5 @@ using Microsoft.Extensions.AI; -using ModelContextProtocol.Types.Authentication; +using ModelContextProtocol.Authentication; using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Protocol.Types; using System.Diagnostics.CodeAnalysis; From e8f701292826c7c04299d645a3392e4f973699de Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 15:40:18 -0700 Subject: [PATCH 101/128] Update McpAuthenticationHandler.cs --- .../Authentication/McpAuthenticationHandler.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index 0221915c..e8723020 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -5,6 +5,7 @@ using ModelContextProtocol.Authentication; using ModelContextProtocol.Utils.Json; using System.Text.Encodings.Web; +using System.Text.Json; namespace ModelContextProtocol.AspNetCore.Authentication; @@ -102,7 +103,7 @@ private async Task HandleResourceMetadataRequestAsync() Response.StatusCode = StatusCodes.Status200OK; Response.ContentType = "application/json"; - var json = System.Text.Json.JsonSerializer.Serialize( + var json = JsonSerializer.Serialize( metadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))); From 94c39bcd921d0a498dc66b7a4ca13333d0e6d4e0 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 19:20:42 -0700 Subject: [PATCH 102/128] No direct inclusion of HTTP client factory --- Directory.Packages.props | 3 -- .../BasicOAuthAuthorizationProvider.cs | 17 +++++------ samples/ProtectedMCPClient/Program.cs | 30 +++++++------------ .../Authentication/AuthorizationHelpers.cs | 24 +++++---------- 4 files changed, 25 insertions(+), 49 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d5fa5762..08930614 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,6 @@ - @@ -19,7 +18,6 @@ - @@ -27,7 +25,6 @@ - diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index 6bb223b6..b9154cbe 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -24,8 +24,8 @@ public class BasicOAuthAuthorizationProvider : ITokenProvider private readonly HttpClient _httpClient; private readonly AuthorizationHelpers _authorizationHelpers; - // Client name for IHttpClientFactory used by the BasicOAuthAuthorizationProvider - public const string HttpClientName = "ProtectedMCPClient.OAuth"; + // Lazy-initialized shared HttpClient for when no client is provided + private static readonly Lazy _defaultHttpClient = new(() => new HttpClient()); private TokenContainer? _token; private AuthorizationServerMetadata? _authServerMetadata; @@ -34,7 +34,7 @@ public class BasicOAuthAuthorizationProvider : ITokenProvider /// Initializes a new instance of the class. /// /// The MCP server URL. - /// The HTTP client factory to use for creating HTTP clients. + /// The HTTP client to use for OAuth requests. If null, a default HttpClient will be used. /// The authorization helpers. /// OAuth client ID. /// OAuth client secret. @@ -42,19 +42,16 @@ public class BasicOAuthAuthorizationProvider : ITokenProvider /// OAuth scopes. public BasicOAuthAuthorizationProvider( Uri serverUrl, - IHttpClientFactory httpClientFactory, - AuthorizationHelpers authorizationHelpers, + HttpClient? httpClient, + AuthorizationHelpers? authorizationHelpers, string clientId = "demo-client", string clientSecret = "", Uri? redirectUri = null, IEnumerable? scopes = null) { _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); - if (httpClientFactory == null) throw new ArgumentNullException(nameof(httpClientFactory)); - _authorizationHelpers = authorizationHelpers ?? throw new ArgumentNullException(nameof(authorizationHelpers)); - - // Get the HttpClient once during construction instead of for each request - _httpClient = httpClientFactory.CreateClient(HttpClientName); + _httpClient = httpClient ?? _defaultHttpClient.Value; + _authorizationHelpers = authorizationHelpers ?? new AuthorizationHelpers(_httpClient); _redirectUri = redirectUri ?? new Uri("http://localhost:8080/callback"); _scopes = scopes?.ToList() ?? []; diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index e8be47f8..ee9cae32 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -14,33 +14,21 @@ static async Task Main(string[] args) var serverUrl = "http://localhost:7071/sse"; - var services = new ServiceCollection(); - services.AddHttpClient(); - + // We can customize a shared HttpClient with a custom handler if desired var sharedHandler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2), PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1) }; - services.AddHttpClient(BasicOAuthAuthorizationProvider.HttpClientName) - .ConfigurePrimaryHttpMessageHandler(() => sharedHandler); - - services.AddHttpClient(AuthorizationHelpers.HttpClientName) - .ConfigurePrimaryHttpMessageHandler(() => sharedHandler); - - services.AddTransient(); + var httpClient = new HttpClient(sharedHandler); - var serviceProvider = services.BuildServiceProvider(); - - var httpClientFactory = serviceProvider.GetRequiredService(); - var authorizationHelpers = serviceProvider.GetRequiredService(); - - // Create the token provider with proper dependencies + // Create the token provider with our custom HttpClient, + // letting the AuthorizationHelpers be created automatically var tokenProvider = new BasicOAuthAuthorizationProvider( new Uri(serverUrl), - httpClientFactory, - authorizationHelpers, + httpClient, + null, // AuthorizationHelpers will be created automatically clientId: "6ad97b5f-7a7b-413f-8603-7a3517d4adb8", redirectUri: new Uri("http://localhost:1179/callback"), scopes: ["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"] @@ -57,7 +45,11 @@ static async Task Main(string[] args) Name = "Secure Weather Client" }; - var transport = new SseClientTransport(transportOptions, tokenProvider); + // Create a transport with authentication support using the correct constructor parameters + var transport = new SseClientTransport( + transportOptions, + tokenProvider + ); var client = await McpClientFactory.CreateAsync(transport); var tools = await client.ListToolsAsync(); diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index 0e50c08f..0c35c2b0 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -10,20 +10,15 @@ namespace ModelContextProtocol.Authentication; public class AuthorizationHelpers { private readonly HttpClient _httpClient; - - /// - /// Client name for IHttpClientFactory used by the AuthorizationHelpers. - /// - public const string HttpClientName = "ModelContextProtocol.Authentication"; + private static readonly Lazy _defaultHttpClient = new(() => new HttpClient()); /// /// Initializes a new instance of the class. /// - /// The HTTP client factory to use for creating HTTP clients. - public AuthorizationHelpers(IHttpClientFactory httpClientFactory) + /// The HTTP client to use for requests. If null, a default HttpClient will be used. + public AuthorizationHelpers(HttpClient? httpClient = null) { - Throw.IfNull(httpClientFactory); - _httpClient = httpClientFactory.CreateClient(HttpClientName); + _httpClient = httpClient ?? _defaultHttpClient.Value; } /// @@ -155,7 +150,7 @@ public async Task ExtractProtectedResourceMetadata( var parts = p.Split(['='], 2); if (parts.Length != 2) { - return new KeyValuePair(string.Empty, string.Empty); + return new KeyValuePair(string.Empty, null); } var key = parts[0].Trim(); @@ -167,16 +162,11 @@ public async Task ExtractProtectedResourceMetadata( value = value.Substring(1, value.Length - 2); } - return new KeyValuePair(key, value); + return new KeyValuePair(key, value); }) .Where(kvp => !string.IsNullOrEmpty(kvp.Key)) .ToDictionary(); - if (paramDict.TryGetValue(parameterName, out var value)) - { - return value; - } - - return null; + return paramDict.TryGetValue(parameterName, out var value) ? value : null; } } \ No newline at end of file From 0504aee62681eed6668ba0f73710a495322bd4ce Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 19:24:01 -0700 Subject: [PATCH 103/128] Remove legacy packages --- src/ModelContextProtocol/ModelContextProtocol.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ModelContextProtocol/ModelContextProtocol.csproj b/src/ModelContextProtocol/ModelContextProtocol.csproj index 500e3503..6de2d30f 100644 --- a/src/ModelContextProtocol/ModelContextProtocol.csproj +++ b/src/ModelContextProtocol/ModelContextProtocol.csproj @@ -36,7 +36,6 @@ - From 7ac5c4a1bd7e6c078727740d4866f60223288128 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 20:33:48 -0700 Subject: [PATCH 104/128] Update signature and make test explicit --- .../Protocol/Transport/SseClientTransport.cs | 9 +++++---- .../Transport/SseClientTransportTests.cs | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs b/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs index 689d82b1..311f88ef 100644 --- a/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs +++ b/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs @@ -58,7 +58,10 @@ public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient /// Configuration options for the transport. /// The authorization provider to use for authentication. /// Logger factory for creating loggers used for diagnostic output during transport operations. - public SseClientTransport(SseClientTransportOptions transportOptions, ITokenProvider authorizationProvider, ILoggerFactory? loggerFactory = null) + /// Optional. The base message handler to use under the authorization handler. + /// If null, a new will be used. This allows for custom HTTP client pipelines (e.g., from HttpClientFactory) + /// to be used in conjunction with the token-based authentication provided by . + public SseClientTransport(SseClientTransportOptions transportOptions, ITokenProvider authorizationProvider, ILoggerFactory? loggerFactory = null, HttpMessageHandler? baseMessageHandler = null) { Throw.IfNull(transportOptions); Throw.IfNull(authorizationProvider); @@ -67,13 +70,11 @@ public SseClientTransport(SseClientTransportOptions transportOptions, ITokenProv _loggerFactory = loggerFactory; Name = transportOptions.Name ?? transportOptions.Endpoint.ToString(); - // Create an auth handler with the authorization provider var authHandler = new AuthorizationDelegatingHandler(authorizationProvider) { - InnerHandler = new HttpClientHandler() + InnerHandler = baseMessageHandler ?? new HttpClientHandler() }; - // Create an HttpClient with the auth handler _httpClient = new HttpClient(authHandler); _ownsHttpClient = true; } diff --git a/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs index baf22f3d..1e96b99c 100644 --- a/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs @@ -35,7 +35,7 @@ public void Constructor_Throws_For_Null_Options() [Fact] public void Constructor_Throws_For_Null_HttpClient() { - var exception = Assert.Throws(() => new SseClientTransport(_transportOptions, null!, LoggerFactory)); + var exception = Assert.Throws(() => new SseClientTransport(_transportOptions, httpClient: null!, LoggerFactory)); Assert.Equal("httpClient", exception.ParamName); } From 7b6c9fd19d4ef222d818addad0e703d93aeefd48 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 20:40:02 -0700 Subject: [PATCH 105/128] URL check --- .../ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index b9154cbe..0f2d69df 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -239,6 +239,12 @@ public BasicOAuthAuthorizationProvider( private Uri BuildAuthorizationUrl(AuthorizationServerMetadata authServerMetadata, string codeChallenge) { + if (authServerMetadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttp && + authServerMetadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException("AuthorizationEndpoint must use HTTP or HTTPS.", nameof(authServerMetadata)); + } + var queryParams = HttpUtility.ParseQueryString(string.Empty); queryParams["client_id"] = _clientId; queryParams["redirect_uri"] = _redirectUri.ToString(); From c4062302510b7ad29e67acc47a028e56c5752341 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 20:51:00 -0700 Subject: [PATCH 106/128] Changes based on feedback --- .../BasicOAuthAuthorizationProvider.cs | 19 ++++++++++++++---- .../Types/AuthorizationServerMetadata.cs | 20 ------------------- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index 0f2d69df..c51b351f 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -27,6 +27,9 @@ public class BasicOAuthAuthorizationProvider : ITokenProvider // Lazy-initialized shared HttpClient for when no client is provided private static readonly Lazy _defaultHttpClient = new(() => new HttpClient()); + // Cached JsonSerializerOptions to avoid recreating it for each deserialization + private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; + private TokenContainer? _token; private AuthorizationServerMetadata? _authServerMetadata; @@ -159,9 +162,17 @@ public BasicOAuthAuthorizationProvider( { var json = await response.Content.ReadAsStringAsync(cancellationToken); var metadata = JsonSerializer.Deserialize( - json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + json, _jsonOptions); - if (metadata != null) return metadata; + if (metadata != null) + { + metadata.ResponseTypesSupported ??= ["code"]; + metadata.GrantTypesSupported ??= ["authorization_code", "refresh_token"]; + metadata.TokenEndpointAuthMethodsSupported ??= ["client_secret_basic"]; + metadata.CodeChallengeMethodsSupported ??= ["S256"]; + + return metadata; + } } } catch (Exception ex) @@ -200,7 +211,7 @@ public BasicOAuthAuthorizationProvider( { var json = await response.Content.ReadAsStringAsync(cancellationToken); var tokenResponse = JsonSerializer.Deserialize( - json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + json, _jsonOptions); if (tokenResponse != null) { @@ -341,7 +352,7 @@ private Uri BuildAuthorizationUrl(AuthorizationServerMetadata authServerMetadata { var json = await response.Content.ReadAsStringAsync(cancellationToken); var tokenResponse = JsonSerializer.Deserialize( - json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + json, _jsonOptions); if (tokenResponse != null) { diff --git a/samples/ProtectedMCPClient/Types/AuthorizationServerMetadata.cs b/samples/ProtectedMCPClient/Types/AuthorizationServerMetadata.cs index 57f886bf..c5fd6253 100644 --- a/samples/ProtectedMCPClient/Types/AuthorizationServerMetadata.cs +++ b/samples/ProtectedMCPClient/Types/AuthorizationServerMetadata.cs @@ -66,24 +66,4 @@ public class AuthorizationServerMetadata /// [JsonPropertyName("scopes_supported")] public List? ScopesSupported { get; set; } - - /// - /// Gets the response types supported by the authorization server or returns the default. - /// - public IReadOnlyList GetResponseTypesSupported() => ResponseTypesSupported ?? new List { "code" }; - - /// - /// Gets the grant types supported by the authorization server or returns the default. - /// - public IReadOnlyList GetGrantTypesSupported() => GrantTypesSupported ?? new List { "authorization_code", "refresh_token" }; - - /// - /// Gets the token endpoint authentication methods supported by the authorization server or returns the default. - /// - public IReadOnlyList GetTokenEndpointAuthMethodsSupported() => TokenEndpointAuthMethodsSupported ?? new List { "client_secret_basic" }; - - /// - /// Gets the code challenge methods supported by the authorization server or returns the default. - /// - public IReadOnlyList GetCodeChallengeMethodsSupported() => CodeChallengeMethodsSupported ?? new List { "S256" }; } \ No newline at end of file From b8954b36209d59c5926ab3713ea8554bfb2adb66 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 20:56:21 -0700 Subject: [PATCH 107/128] Update to use a HttpClientFactory --- samples/ProtectedMCPServer/Program.cs | 6 +++--- samples/ProtectedMCPServer/Tools/WeatherTools.cs | 15 +++++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 076b51bc..1e658b4f 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -81,11 +81,11 @@ .WithTools() .WithHttpTransport(); -builder.Services.AddSingleton(_ => +// Configure HttpClientFactory for weather.gov API +builder.Services.AddHttpClient("WeatherApi", client => { - var client = new HttpClient() { BaseAddress = new Uri("https://api.weather.gov") }; + client.BaseAddress = new Uri("https://api.weather.gov"); client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0")); - return client; }); var app = builder.Build(); diff --git a/samples/ProtectedMCPServer/Tools/WeatherTools.cs b/samples/ProtectedMCPServer/Tools/WeatherTools.cs index f8b2abc6..7c8c0851 100644 --- a/samples/ProtectedMCPServer/Tools/WeatherTools.cs +++ b/samples/ProtectedMCPServer/Tools/WeatherTools.cs @@ -9,11 +9,18 @@ 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 static async Task GetAlerts( - HttpClient client, + public async Task 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(); @@ -37,11 +44,11 @@ public static async Task GetAlerts( } [McpServerTool, Description("Get weather forecast for a location.")] - public static async Task GetForecast( - HttpClient client, + public async Task 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() From 0b030c23c5f7e90a4c732d6077c11ec474a41b6d Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 21:01:55 -0700 Subject: [PATCH 108/128] Update McpAuthenticationHandler.cs --- .../Authentication/McpAuthenticationHandler.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index e8723020..d8f7a013 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -16,6 +16,7 @@ namespace ModelContextProtocol.AspNetCore.Authentication; public class McpAuthenticationHandler : AuthenticationHandler, IAuthenticationRequestHandler { private readonly IOptionsMonitor _optionsMonitor; + private string _resourceMetadataPath; /// /// Initializes a new instance of the class. @@ -27,6 +28,7 @@ public McpAuthenticationHandler( : base(options, logger, encoder) { _optionsMonitor = options; + _resourceMetadataPath = options.CurrentValue.ResourceMetadataUri.ToString(); } /// @@ -34,11 +36,9 @@ public async Task HandleRequestAsync() { // Check if the request is for the resource metadata endpoint string requestPath = Request.Path.Value ?? string.Empty; - var options = _optionsMonitor.CurrentValue; - string resourceMetadataPath = options.ResourceMetadataUri.ToString(); // If the path doesn't match, let the request continue through the pipeline - if (!string.Equals(requestPath, resourceMetadataPath, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(requestPath, _resourceMetadataPath, StringComparison.OrdinalIgnoreCase)) { return false; } @@ -61,6 +61,13 @@ private string GetAbsoluteResourceMetadataUri() var options = _optionsMonitor.CurrentValue; var resourceMetadataUri = options.ResourceMetadataUri; + // If the options have changed, update the cached path + string currentPath = resourceMetadataUri.ToString(); + if (_resourceMetadataPath != currentPath) + { + _resourceMetadataPath = currentPath; + } + if (resourceMetadataUri.IsAbsoluteUri) { return resourceMetadataUri.ToString(); @@ -68,9 +75,8 @@ private string GetAbsoluteResourceMetadataUri() // For relative URIs, combine with the base URL string baseUrl = GetBaseUrl(); - string resourceMetadataPath = resourceMetadataUri.ToString(); - if (!Uri.TryCreate(baseUrl + resourceMetadataPath, UriKind.Absolute, out var absoluteUri)) + if (!Uri.TryCreate(baseUrl + _resourceMetadataPath, UriKind.Absolute, out var absoluteUri)) { throw new InvalidOperationException("Could not create absolute URI for resource metadata."); } From 7c6e406af5723546b25d3d2ae595ac656e1bf968 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 21:09:58 -0700 Subject: [PATCH 109/128] Update based on feedback --- .../Authentication/McpAuthenticationHandler.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index d8f7a013..9b0c86b1 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -43,8 +43,9 @@ public async Task HandleRequestAsync() return false; } - // This is a request for resource metadata - handle it - await HandleResourceMetadataRequestAsync(); + // Use the request's cancellation token if available + var cancellationToken = Request.HttpContext.RequestAborted; + await HandleResourceMetadataRequestAsync(cancellationToken); return true; } @@ -87,7 +88,8 @@ private string GetAbsoluteResourceMetadataUri() /// /// Handles the resource metadata request. /// - private async Task HandleResourceMetadataRequestAsync() + /// A token to cancel the operation. + private Task HandleResourceMetadataRequestAsync(CancellationToken cancellationToken = default) { // Get resource metadata from options, using the dynamic provider if available var options = _optionsMonitor.CurrentValue; @@ -113,7 +115,7 @@ private async Task HandleResourceMetadataRequestAsync() metadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))); - await Response.WriteAsync(json); + return Response.WriteAsync(json, cancellationToken); } /// From 94200134979faa51d569d03f9b17c4d2759d1d5a Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 21:14:45 -0700 Subject: [PATCH 110/128] Update McpAuthenticationOptions.cs --- .../McpAuthenticationOptions.cs | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs index edd7223c..5b231cc3 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs @@ -11,6 +11,22 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions { private static readonly Uri DefaultResourceMetadataUri = new("/.well-known/oauth-protected-resource", UriKind.Relative); + /// + /// Initializes a new instance of the class. + /// + public McpAuthenticationOptions() + { + // Initialize the base property instead of hiding it with 'new' + base.ForwardAuthenticate = "Bearer"; + + // Initialize properties in constructor instead of using property initializers + ResourceMetadataUri = DefaultResourceMetadataUri; + ResourceMetadata = new ProtectedResourceMetadata(); + + // Initialize events + Events = new McpAuthenticationEvents(); + } + /// /// Gets or sets the events used to handle authentication events. /// @@ -20,21 +36,13 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions set { base.Events = value; } } - /// - /// Gets or sets the scheme to use for forward authentication. - /// - /// - /// This is currently set as a constant to avoid adding a package dependency. - /// - public new string ForwardAuthenticate { get; set; } = "Bearer"; - /// /// The URI to the resource metadata document. /// /// /// This URI will be included in the WWW-Authenticate header when a 401 response is returned. /// - public Uri ResourceMetadataUri { get; set; } = DefaultResourceMetadataUri; + public Uri ResourceMetadataUri { get; set; } /// /// Gets or sets the static protected resource metadata. @@ -44,7 +52,7 @@ public class McpAuthenticationOptions : AuthenticationSchemeOptions /// supported scopes, and other information needed for clients to authenticate. /// This property is used when is not set. /// - public ProtectedResourceMetadata ResourceMetadata { get; set; } = new ProtectedResourceMetadata(); + public ProtectedResourceMetadata ResourceMetadata { get; set; } /// /// Gets or sets a delegate that dynamically provides resource metadata based on the HTTP context. From 4fbf2e92ec2e469648029ea6fca8860c09d66eb9 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 21:26:26 -0700 Subject: [PATCH 111/128] Update McpAuthenticationOptions.cs --- .../McpAuthenticationOptions.cs | 68 +++++++++++++++---- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs index 5b231cc3..bfb25df9 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs @@ -10,20 +10,17 @@ namespace ModelContextProtocol.AspNetCore.Authentication; public class McpAuthenticationOptions : AuthenticationSchemeOptions { private static readonly Uri DefaultResourceMetadataUri = new("/.well-known/oauth-protected-resource", UriKind.Relative); + private Func? _resourceMetadataProvider; + private ProtectedResourceMetadata _resourceMetadata; /// /// Initializes a new instance of the class. /// public McpAuthenticationOptions() { - // Initialize the base property instead of hiding it with 'new' base.ForwardAuthenticate = "Bearer"; - - // Initialize properties in constructor instead of using property initializers ResourceMetadataUri = DefaultResourceMetadataUri; - ResourceMetadata = new ProtectedResourceMetadata(); - - // Initialize events + _resourceMetadata = new ProtectedResourceMetadata(); Events = new McpAuthenticationEvents(); } @@ -50,9 +47,19 @@ public McpAuthenticationOptions() /// /// This contains the OAuth metadata for the protected resource, including authorization servers, /// supported scopes, and other information needed for clients to authenticate. - /// This property is used when is not set. + /// Setting this property will automatically update the + /// to return this static instance. /// - public ProtectedResourceMetadata ResourceMetadata { get; set; } + public ProtectedResourceMetadata ResourceMetadata + { + get => _resourceMetadata; + set + { + _resourceMetadata = value ?? new ProtectedResourceMetadata(); + // When static metadata is set, update the provider to use it + _resourceMetadataProvider = _ => _resourceMetadata; + } + } /// /// Gets or sets a delegate that dynamically provides resource metadata based on the HTTP context. @@ -62,7 +69,39 @@ public McpAuthenticationOptions() /// allowing dynamic customization based on the caller or other contextual information. /// This takes precedence over the static property. /// - public Func? ResourceMetadataProvider { get; set; } + public Func? ResourceMetadataProvider + { + get => _resourceMetadataProvider; + set => _resourceMetadataProvider = value ?? (_ => _resourceMetadata); + } + + /// + /// Sets a static resource metadata instance that will be returned for all requests. + /// + /// The static resource metadata to use. + /// The current options instance for method chaining. + /// + /// This is a convenience method equivalent to setting the property. + /// + public McpAuthenticationOptions UseStaticResourceMetadata(ProtectedResourceMetadata metadata) + { + ResourceMetadata = metadata ?? new ProtectedResourceMetadata(); + return this; + } + + /// + /// Sets a delegate to dynamically provide resource metadata for each request. + /// + /// A delegate that returns resource metadata for a given HTTP context. + /// The current options instance for method chaining. + /// + /// This is a convenience method equivalent to setting the property. + /// + public McpAuthenticationOptions UseDynamicResourceMetadata(Func provider) + { + ResourceMetadataProvider = provider ?? throw new ArgumentNullException(nameof(provider)); + return this; + } /// /// Gets the resource metadata for the current request. @@ -71,11 +110,10 @@ public McpAuthenticationOptions() /// The resource metadata to use for the current request. internal ProtectedResourceMetadata GetResourceMetadata(HttpContext context) { - if (ResourceMetadataProvider != null) - { - return ResourceMetadataProvider(context); - } - - return ResourceMetadata; + var provider = _resourceMetadataProvider; + + return provider != null + ? provider(context) + : _resourceMetadata; } } \ No newline at end of file From 22962cf070dcfb8cd8e84c32dd89065774d2f81e Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 21:35:31 -0700 Subject: [PATCH 112/128] Minor optimizations to the delegating handler --- .../AuthorizationDelegatingHandler.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index 669d00c6..b8d629a5 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -59,19 +59,20 @@ private async Task HandleUnauthorizedResponseAsync( var serverSchemes = ExtractServerSupportedSchemes(response); // Find the intersection between what the server supports and what our provider supports - var supportedSchemes = _authorizationProvider.SupportedSchemes.ToList(); string? bestSchemeMatch = null; // First try to find a direct match with the current scheme if it's still valid string schemeUsed = originalRequest.Headers.Authorization?.Scheme ?? _currentScheme ?? string.Empty; - if (serverSchemes.Contains(schemeUsed) && supportedSchemes.Contains(schemeUsed)) + if (!string.IsNullOrEmpty(schemeUsed) && + serverSchemes.Contains(schemeUsed) && + _authorizationProvider.SupportedSchemes.Contains(schemeUsed)) { bestSchemeMatch = schemeUsed; } else { // Find the first server scheme that's in our supported set - bestSchemeMatch = serverSchemes.Intersect(supportedSchemes, StringComparer.OrdinalIgnoreCase).FirstOrDefault(); + bestSchemeMatch = serverSchemes.Intersect(_authorizationProvider.SupportedSchemes, StringComparer.OrdinalIgnoreCase).FirstOrDefault(); // If no match was found, either throw an exception or use default if (bestSchemeMatch is null) @@ -80,11 +81,11 @@ private async Task HandleUnauthorizedResponseAsync( { throw new InvalidOperationException( $"No matching authentication scheme found. Server supports: [{string.Join(", ", serverSchemes)}], " + - $"Provider supports: [{string.Join(", ", supportedSchemes)}]."); + $"Provider supports: [{string.Join(", ", _authorizationProvider.SupportedSchemes)}]."); } // If the server didn't specify any schemes, use the provider's default - bestSchemeMatch = supportedSchemes.FirstOrDefault(); + bestSchemeMatch = _authorizationProvider.SupportedSchemes.FirstOrDefault(); } } @@ -148,9 +149,9 @@ private async Task HandleUnauthorizedResponseAsync( /// /// Extracts the authentication schemes that the server supports from the WWW-Authenticate headers. /// - private static List ExtractServerSupportedSchemes(HttpResponseMessage response) + private static HashSet ExtractServerSupportedSchemes(HttpResponseMessage response) { - var serverSchemes = new List(); + var serverSchemes = new HashSet(StringComparer.OrdinalIgnoreCase); if (response.Headers.Contains("WWW-Authenticate")) { From 5c94158faff32c4f33fd7afb7012ff603a40f1be Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 21:37:03 -0700 Subject: [PATCH 113/128] Update AuthorizationDelegatingHandler.cs --- .../Authentication/AuthorizationDelegatingHandler.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index b8d629a5..65a6081d 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -160,10 +160,7 @@ private static HashSet ExtractServerSupportedSchemes(HttpResponseMessage // Extract the scheme from the WWW-Authenticate header // Format is typically: "Scheme param1=value1, param2=value2" string scheme = authHeader.Split(SchemeSplitDelimiters, StringSplitOptions.RemoveEmptyEntries)[0]; - if (!string.IsNullOrEmpty(scheme)) - { - serverSchemes.Add(scheme); - } + serverSchemes.Add(scheme); } } From 0abea2244517fb1202a61c4fd1588bce33f98c9c Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 21:39:26 -0700 Subject: [PATCH 114/128] Update AuthorizationHelpers.cs --- src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index 0c35c2b0..4e8469f8 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -37,7 +37,7 @@ public AuthorizationHelpers(HttpClient? httpClient = null) var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var content = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return await JsonSerializer.DeserializeAsync(content, McpJsonUtilities.JsonContext.Default.ProtectedResourceMetadata, cancellationToken).ConfigureAwait(false); From 58a7a3dca4ca1e8a529d45795a848ec5c40c2964 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 21:44:51 -0700 Subject: [PATCH 115/128] Update AuthorizationHelpers.cs --- .../Authentication/AuthorizationHelpers.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index 4e8469f8..dd8e36b1 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -1,4 +1,5 @@ -using ModelContextProtocol.Utils; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Utils.Json; using System.Text.Json; @@ -10,15 +11,18 @@ namespace ModelContextProtocol.Authentication; public class AuthorizationHelpers { private readonly HttpClient _httpClient; + private readonly ILogger _logger; private static readonly Lazy _defaultHttpClient = new(() => new HttpClient()); /// /// Initializes a new instance of the class. /// /// The HTTP client to use for requests. If null, a default HttpClient will be used. - public AuthorizationHelpers(HttpClient? httpClient = null) + /// The logger to use. If null, a NullLogger will be used. + public AuthorizationHelpers(HttpClient? httpClient = null, ILogger? logger = null) { _httpClient = httpClient ?? _defaultHttpClient.Value; + _logger = logger ?? NullLogger.Instance; } /// @@ -42,8 +46,9 @@ public AuthorizationHelpers(HttpClient? httpClient = null) McpJsonUtilities.JsonContext.Default.ProtectedResourceMetadata, cancellationToken).ConfigureAwait(false); } - catch (Exception) + catch (Exception ex) { + _logger.LogError(ex, $"Failed to fetch protected resource metadata from {metadataUrl}"); return null; } } From b3b816836f99d6acb28c619beb1b8bdcc15926f6 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 21:46:29 -0700 Subject: [PATCH 116/128] Update AuthorizationHelpers.cs --- src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index dd8e36b1..8e1fd676 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -96,7 +96,7 @@ public async Task ExtractProtectedResourceMetadata( } // Extract the WWW-Authenticate header - if (!response.Headers.WwwAuthenticate.Any()) + if (response.Headers.WwwAuthenticate.Count == 0) { throw new InvalidOperationException("The 401 response does not contain a WWW-Authenticate header"); } From aad7ad5c8c85eb24083f700257c9e39939cf1a71 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 21:51:33 -0700 Subject: [PATCH 117/128] Update AuthorizationHelpers.cs --- .../Authentication/AuthorizationHelpers.cs | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index 8e1fd676..d419ecfe 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -148,30 +148,37 @@ public async Task ExtractProtectedResourceMetadata( private static string? ParseWwwAuthenticateParameters(string parameters, string parameterName) { // Handle parameters in the format: param1="value1", param2="value2" - var paramDict = parameters.Split(',') - .Select(p => p.Trim()) - .Select(p => + var paramParts = parameters.Split(','); + Dictionary paramDict = new(paramParts.Length); + + foreach (var part in paramParts) + { + var trimmedPart = part.Trim(); + var keyValuePair = trimmedPart.Split(['='], 2); + + if (keyValuePair.Length != 2) { - var parts = p.Split(['='], 2); - if (parts.Length != 2) - { - return new KeyValuePair(string.Empty, null); - } - - var key = parts[0].Trim(); - var value = parts[1].Trim(); - - // Remove surrounding quotes if present - if (value.StartsWith("\"") && value.EndsWith("\"")) - { - value = value.Substring(1, value.Length - 2); - } - - return new KeyValuePair(key, value); - }) - .Where(kvp => !string.IsNullOrEmpty(kvp.Key)) - .ToDictionary(); + continue; + } + + var key = keyValuePair[0].Trim(); + if (string.IsNullOrEmpty(key)) + { + continue; + } + + var value = keyValuePair[1].Trim(); + + // Remove surrounding quotes if present + if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') + { + value = value.Substring(1, value.Length - 2); + } + + paramDict[key] = value; + } - return paramDict.TryGetValue(parameterName, out var value) ? value : null; + paramDict.TryGetValue(parameterName, out var result); + return result; } } \ No newline at end of file From 43eb6ff4a30ece92566d3807da8a94aec86e9b1c Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 22:02:45 -0700 Subject: [PATCH 118/128] Add record support for unauthorized result --- .../BasicOAuthAuthorizationProvider.cs | 10 +++++----- .../Authentication/ITokenProvider.cs | 6 +++--- .../Authentication/McpUnauthorizedResponseResult.cs | 8 ++++++++ 3 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 src/ModelContextProtocol/Authentication/McpUnauthorizedResponseResult.cs diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index c51b351f..5bec8dee 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -78,7 +78,7 @@ public BasicOAuthAuthorizationProvider( } /// - public async Task<(bool Success, string? RecommendedScheme)> HandleUnauthorizedResponseAsync( + public async Task HandleUnauthorizedResponseAsync( HttpResponseMessage response, string scheme, CancellationToken cancellationToken = default) @@ -86,7 +86,7 @@ public BasicOAuthAuthorizationProvider( // This provider only supports Bearer scheme if (scheme != "Bearer") { - return (false, null); + return new McpUnauthorizedResponseResult(false, null); } try @@ -111,17 +111,17 @@ public BasicOAuthAuthorizationProvider( if (token != null) { _token = token; - return (true, "Bearer"); + return new McpUnauthorizedResponseResult(true, "Bearer"); } } } - return (false, null); + return new McpUnauthorizedResponseResult(false, null); } catch (Exception ex) { Console.WriteLine($"Error handling auth challenge: {ex.Message}"); - return (false, null); + return new McpUnauthorizedResponseResult(false, null); } } diff --git a/src/ModelContextProtocol/Authentication/ITokenProvider.cs b/src/ModelContextProtocol/Authentication/ITokenProvider.cs index 869ea0b2..e3af2e29 100644 --- a/src/ModelContextProtocol/Authentication/ITokenProvider.cs +++ b/src/ModelContextProtocol/Authentication/ITokenProvider.cs @@ -38,10 +38,10 @@ public interface ITokenProvider /// The authentication scheme that was used when the unauthorized response was received. /// A token to cancel the operation. /// - /// A tuple containing a boolean indicating if the provider was able to handle the unauthorized response, - /// and the authentication scheme that should be used for the next attempt. + /// A result object indicating if the provider was able to handle the unauthorized response, + /// and the authentication scheme that should be used for the next attempt, if any. /// - Task<(bool Success, string? RecommendedScheme)> HandleUnauthorizedResponseAsync( + Task HandleUnauthorizedResponseAsync( HttpResponseMessage response, string scheme, CancellationToken cancellationToken = default); diff --git a/src/ModelContextProtocol/Authentication/McpUnauthorizedResponseResult.cs b/src/ModelContextProtocol/Authentication/McpUnauthorizedResponseResult.cs new file mode 100644 index 00000000..70563433 --- /dev/null +++ b/src/ModelContextProtocol/Authentication/McpUnauthorizedResponseResult.cs @@ -0,0 +1,8 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Represents the result of handling an unauthorized response from a resource. +/// +/// Indicates if the provider was able to handle the unauthorized response. +/// The authentication scheme that should be used for the next attempt, if any. +public record McpUnauthorizedResponseResult(bool Success, string? RecommendedScheme); \ No newline at end of file From 9a4cea0ef1e8ae64e1da54a2736852afe6c2e103 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 22:07:41 -0700 Subject: [PATCH 119/128] Update BasicOAuthAuthorizationProvider.cs --- .../BasicOAuthAuthorizationProvider.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index 5bec8dee..2a5de2dd 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -16,6 +16,11 @@ namespace ProtectedMCPClient; /// public class BasicOAuthAuthorizationProvider : ITokenProvider { + /// + /// The Bearer authentication scheme. + /// + private const string BearerScheme = "Bearer"; + private readonly Uri _serverUrl; private readonly Uri _redirectUri; private readonly List _scopes; @@ -63,13 +68,13 @@ public BasicOAuthAuthorizationProvider( } /// - public IEnumerable SupportedSchemes => new[] { "Bearer" }; + public IEnumerable SupportedSchemes => new[] { BearerScheme }; /// public Task GetCredentialAsync(string scheme, Uri resourceUri, CancellationToken cancellationToken = default) { // This provider only supports Bearer tokens - if (scheme != "Bearer") + if (scheme != BearerScheme) { return Task.FromResult(null); } @@ -84,7 +89,7 @@ public async Task HandleUnauthorizedResponseAsync CancellationToken cancellationToken = default) { // This provider only supports Bearer scheme - if (scheme != "Bearer") + if (scheme != BearerScheme) { return new McpUnauthorizedResponseResult(false, null); } @@ -111,7 +116,7 @@ public async Task HandleUnauthorizedResponseAsync if (token != null) { _token = token; - return new McpUnauthorizedResponseResult(true, "Bearer"); + return new McpUnauthorizedResponseResult(true, BearerScheme); } } } From b3766aa56188b3d0fbd6aa21e729606a100bba54 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 22:12:25 -0700 Subject: [PATCH 120/128] Update ProtectedResourceMetadata.cs --- .../ProtectedResourceMetadata.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/ProtectedResourceMetadata.cs b/src/ModelContextProtocol/Authentication/ProtectedResourceMetadata.cs index d9d160bd..772be5dc 100644 --- a/src/ModelContextProtocol/Authentication/ProtectedResourceMetadata.cs +++ b/src/ModelContextProtocol/Authentication/ProtectedResourceMetadata.cs @@ -8,6 +8,16 @@ namespace ModelContextProtocol.Authentication; /// public class ProtectedResourceMetadata { + /// + /// Initializes a new instance of the class. + /// + public ProtectedResourceMetadata() + { + AuthorizationServers = []; + BearerMethodsSupported = []; + ScopesSupported = []; + } + /// /// The resource URI. /// @@ -15,7 +25,7 @@ public class ProtectedResourceMetadata /// REQUIRED. The protected resource's resource identifier. /// [JsonPropertyName("resource")] - public Uri Resource { get; set; } = null!; + public required Uri Resource { get; init; } /// /// The list of authorization server URIs. @@ -25,7 +35,7 @@ public class ProtectedResourceMetadata /// for authorization servers that can be used with this protected resource. /// [JsonPropertyName("authorization_servers")] - public List AuthorizationServers { get; set; } = new(); + public List AuthorizationServers { get; set; } /// /// The supported bearer token methods. @@ -35,7 +45,7 @@ public class ProtectedResourceMetadata /// to the protected resource. Defined values are ["header", "body", "query"]. /// [JsonPropertyName("bearer_methods_supported")] - public List BearerMethodsSupported { get; set; } = new(); + public List BearerMethodsSupported { get; set; } /// /// The supported scopes. @@ -45,7 +55,7 @@ public class ProtectedResourceMetadata /// requests to request access to this protected resource. /// [JsonPropertyName("scopes_supported")] - public List ScopesSupported { get; set; } = new(); + public List ScopesSupported { get; set; } /// /// URL of the protected resource's JSON Web Key (JWK) Set document. From bc1566e93fc34bd4f8f04bd7e2761fbd05d3e3bd Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 23:46:51 -0700 Subject: [PATCH 121/128] Proper check for resource metadata --- .../BasicOAuthAuthorizationProvider.cs | 3 +- samples/ProtectedMCPServer/Program.cs | 7 +- .../McpAuthenticationHandler.cs | 7 +- .../McpAuthenticationOptions.cs | 14 +- .../Authentication/AuthorizationHelpers.cs | 172 ++++++++++++++---- 5 files changed, 146 insertions(+), 57 deletions(-) diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs index 2a5de2dd..b9b2eed6 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs @@ -31,8 +31,7 @@ public class BasicOAuthAuthorizationProvider : ITokenProvider // Lazy-initialized shared HttpClient for when no client is provided private static readonly Lazy _defaultHttpClient = new(() => new HttpClient()); - - // Cached JsonSerializerOptions to avoid recreating it for each deserialization + private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; private TokenContainer? _token; diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs index 1e658b4f..54d21a9d 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMCPServer/Program.cs @@ -57,18 +57,19 @@ }) .AddMcp(options => { - options.ResourceMetadataProvider = context => + options.ProtectedResourceMetadataProvider = context => { var metadata = new ProtectedResourceMetadata { + Resource = new Uri("http://localhost"), BearerMethodsSupported = { "header" }, ResourceDocumentation = new Uri("https://docs.example.com/api/weather"), AuthorizationServers = { new Uri($"{instance}{tenantId}/v2.0") } }; - metadata.ScopesSupported.AddRange(new[] { + metadata.ScopesSupported.AddRange([ "api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read" - }); + ]); return metadata; }; diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index 9b0c86b1..e0d87687 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -98,16 +98,13 @@ private Task HandleResourceMetadataRequestAsync(CancellationToken cancellationTo // Create a copy to avoid modifying the original var metadata = new ProtectedResourceMetadata { + Resource = resourceMetadata.Resource ?? new Uri(GetBaseUrl()), AuthorizationServers = [.. resourceMetadata.AuthorizationServers], BearerMethodsSupported = [.. resourceMetadata.BearerMethodsSupported], ScopesSupported = [.. resourceMetadata.ScopesSupported], - ResourceDocumentation = resourceMetadata.ResourceDocumentation, - Resource = resourceMetadata.Resource + ResourceDocumentation = resourceMetadata.ResourceDocumentation }; - // Set default resource if not set - metadata.Resource ??= new Uri(GetBaseUrl()); - Response.StatusCode = StatusCodes.Status200OK; Response.ContentType = "application/json"; diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs index bfb25df9..6434fe06 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs @@ -20,7 +20,7 @@ public McpAuthenticationOptions() { base.ForwardAuthenticate = "Bearer"; ResourceMetadataUri = DefaultResourceMetadataUri; - _resourceMetadata = new ProtectedResourceMetadata(); + _resourceMetadata = new ProtectedResourceMetadata() { Resource = new Uri("http://localhost") }; Events = new McpAuthenticationEvents(); } @@ -47,7 +47,7 @@ public McpAuthenticationOptions() /// /// This contains the OAuth metadata for the protected resource, including authorization servers, /// supported scopes, and other information needed for clients to authenticate. - /// Setting this property will automatically update the + /// Setting this property will automatically update the /// to return this static instance. /// public ProtectedResourceMetadata ResourceMetadata @@ -55,7 +55,7 @@ public ProtectedResourceMetadata ResourceMetadata get => _resourceMetadata; set { - _resourceMetadata = value ?? new ProtectedResourceMetadata(); + _resourceMetadata = value ?? new ProtectedResourceMetadata() { Resource = new Uri("http://localhost") }; // When static metadata is set, update the provider to use it _resourceMetadataProvider = _ => _resourceMetadata; } @@ -69,7 +69,7 @@ public ProtectedResourceMetadata ResourceMetadata /// allowing dynamic customization based on the caller or other contextual information. /// This takes precedence over the static property. /// - public Func? ResourceMetadataProvider + public Func? ProtectedResourceMetadataProvider { get => _resourceMetadataProvider; set => _resourceMetadataProvider = value ?? (_ => _resourceMetadata); @@ -85,7 +85,7 @@ public Func? ResourceMetadataProvider /// public McpAuthenticationOptions UseStaticResourceMetadata(ProtectedResourceMetadata metadata) { - ResourceMetadata = metadata ?? new ProtectedResourceMetadata(); + ResourceMetadata = metadata ?? new ProtectedResourceMetadata() { Resource = new Uri("http://localhost") }; return this; } @@ -95,11 +95,11 @@ public McpAuthenticationOptions UseStaticResourceMetadata(ProtectedResourceMetad /// A delegate that returns resource metadata for a given HTTP context. /// The current options instance for method chaining. /// - /// This is a convenience method equivalent to setting the property. + /// This is a convenience method equivalent to setting the property. /// public McpAuthenticationOptions UseDynamicResourceMetadata(Func provider) { - ResourceMetadataProvider = provider ?? throw new ArgumentNullException(nameof(provider)); + ProtectedResourceMetadataProvider = provider ?? throw new ArgumentNullException(nameof(provider)); return this; } diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index d419ecfe..181e198e 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -13,6 +13,11 @@ public class AuthorizationHelpers private readonly HttpClient _httpClient; private readonly ILogger _logger; private static readonly Lazy _defaultHttpClient = new(() => new HttpClient()); + + /// + /// The common well-known path prefix for resource metadata. + /// + private static readonly string WellKnownPathPrefix = "/.well-known/"; /// /// Initializes a new instance of the class. @@ -54,25 +59,100 @@ public AuthorizationHelpers(HttpClient? httpClient = null, ILogger? logger = nul } /// - /// Verifies that the resource URI in the metadata matches the server URL. + /// Verifies that the resource URI in the metadata exactly matches the server URL as required by the RFC. /// /// The metadata to verify. - /// The server URL to compare against. - /// True if the resource URI matches the server, otherwise false. - private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri serverUrl) + /// The server URL to compare against. + /// True if the resource URI exactly matches the server, otherwise false. + private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri resourceLocation) { - if (protectedResourceMetadata.Resource == null || serverUrl == null) + if (protectedResourceMetadata.Resource == null || resourceLocation == null) { return false; } - // Compare hosts using Uri properties directly - return Uri.Compare( - protectedResourceMetadata.Resource, - serverUrl, - UriComponents.Host, - UriFormat.UriEscaped, - StringComparison.OrdinalIgnoreCase) == 0; + // Per RFC: The resource value must be identical to the URL that the client used + // to make the request to the resource server. Compare entire URIs, not just the host. + + // Normalize the URIs to ensure consistent comparison + string normalizedMetadataResource = NormalizeUri(protectedResourceMetadata.Resource); + string normalizedResourceLocation = NormalizeUri(resourceLocation); + + return string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Normalizes a URI for consistent comparison by removing ports and trailing slashes. + /// + /// The URI to normalize. + /// A normalized string representation of the URI. + private static string NormalizeUri(Uri uri) + { + // Create a builder that will normalize the URI + var builder = new UriBuilder(uri) + { + Port = -1 // Always remove port specification regardless of whether it's default or not + }; + + // Ensure consistent path representation (remove trailing slash if it's just "/") + if (builder.Path == "/") + { + builder.Path = string.Empty; + } + // Remove trailing slash for other paths + else if (builder.Path.Length > 1 && builder.Path.EndsWith("/")) + { + builder.Path = builder.Path.TrimEnd('/'); + } + + return builder.Uri.ToString().TrimEnd('/'); + } + + /// + /// Extracts the base resource URI from a well-known path URL. + /// + /// The metadata URI containing a well-known path. + /// The base URI without the well-known path component. + /// Thrown when the URI does not contain a valid well-known path. + private Uri ExtractBaseResourceUri(Uri metadataUri) + { + // Get the absolute URI path to check for well-known path + string absoluteUriString = metadataUri.AbsoluteUri; + + // Find the well-known path index directly with string operations + // This avoids the allocation from WellKnownPathPrefix.AsSpan() + int wellKnownIndex = absoluteUriString.IndexOf(WellKnownPathPrefix, StringComparison.OrdinalIgnoreCase); + + // Validate that the URL contains the well-known path + if (wellKnownIndex <= 0) + { + throw new InvalidOperationException( + $"Resource metadata URL '{metadataUri}' does not contain a valid well-known path format (/.well-known/)"); + } + + // Get just the path segment before .well-known directly on the URI + int wellKnownPathIndex = metadataUri.AbsolutePath.IndexOf(WellKnownPathPrefix, StringComparison.OrdinalIgnoreCase); + + // Create a new URI builder using the original scheme and authority + var baseUriBuilder = new UriBuilder(metadataUri) + { + Path = wellKnownPathIndex > 0 ? metadataUri.AbsolutePath.Substring(0, wellKnownPathIndex) : "/", + Fragment = string.Empty, + Query = string.Empty + }; + + // Ensure the path ends with exactly one slash for consistency + string path = baseUriBuilder.Path; + if (string.IsNullOrEmpty(path)) + { + baseUriBuilder.Path = "/"; + } + else if (!path.EndsWith("/")) + { + baseUriBuilder.Path += "/"; + } + + return baseUriBuilder.Uri; } /// @@ -105,16 +185,10 @@ public async Task ExtractProtectedResourceMetadata( string? resourceMetadataUrl = null; foreach (var header in response.Headers.WwwAuthenticate) { - if (header.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(header.Parameter)) { - var parameters = header.Parameter; - if (string.IsNullOrEmpty(parameters)) - { - continue; - } - - // Parse the parameters to find resource_metadata - resourceMetadataUrl = ParseWwwAuthenticateParameters(parameters, "resource_metadata"); + resourceMetadataUrl = ParseWwwAuthenticateParameters(header.Parameter, "resource_metadata"); if (resourceMetadataUrl != null) { break; @@ -129,11 +203,20 @@ public async Task ExtractProtectedResourceMetadata( Uri metadataUri = new(resourceMetadataUrl); - var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException($"Failed to fetch resource metadata from {resourceMetadataUrl}"); - if (!VerifyResourceMatch(metadata, serverUrl)) + var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken).ConfigureAwait(false); + if (metadata == null) + { + throw new InvalidOperationException($"Failed to fetch resource metadata from {resourceMetadataUrl}"); + } + + // Extract the base URI from the metadata URL + Uri urlToValidate = ExtractBaseResourceUri(metadataUri); + _logger.LogDebug($"Validating resource metadata against base URL: {urlToValidate}"); + + if (!VerifyResourceMatch(metadata, urlToValidate)) { throw new InvalidOperationException( - $"Resource URI in metadata ({metadata.Resource}) does not match the server URI ({serverUrl})"); + $"Resource URI in metadata ({metadata.Resource}) does not match the expected URI ({urlToValidate})"); } return metadata; @@ -147,38 +230,47 @@ public async Task ExtractProtectedResourceMetadata( /// The value of the parameter, or null if not found. private static string? ParseWwwAuthenticateParameters(string parameters, string parameterName) { - // Handle parameters in the format: param1="value1", param2="value2" - var paramParts = parameters.Split(','); - Dictionary paramDict = new(paramParts.Length); + // More efficient parameter parsing that reduces allocations + ReadOnlySpan parametersSpan = parameters.AsSpan(); + ReadOnlySpan searchParam = parameterName.AsSpan(); - foreach (var part in paramParts) + // Fast path for common case - direct parameter search + int paramNameIndex = parametersSpan.IndexOf(searchParam, StringComparison.OrdinalIgnoreCase); + if (paramNameIndex == -1) + { + return null; + } + + // Use span-based parsing to reduce allocations + var parts = parameters.Split(','); + foreach (var part in parts) { - var trimmedPart = part.Trim(); - var keyValuePair = trimmedPart.Split(['='], 2); + ReadOnlySpan trimmedPart = part.AsSpan().Trim(); + int equalsIndex = trimmedPart.IndexOf('='); - if (keyValuePair.Length != 2) + if (equalsIndex <= 0 || equalsIndex == trimmedPart.Length - 1) { continue; } - var key = keyValuePair[0].Trim(); - if (string.IsNullOrEmpty(key)) + ReadOnlySpan key = trimmedPart.Slice(0, equalsIndex).Trim(); + + if (key.IsEmpty || !key.Equals(searchParam, StringComparison.OrdinalIgnoreCase)) { continue; } - var value = keyValuePair[1].Trim(); + ReadOnlySpan value = trimmedPart.Slice(equalsIndex + 1).Trim(); - // Remove surrounding quotes if present + // Remove quotes if present if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') { - value = value.Substring(1, value.Length - 2); + value = value.Slice(1, value.Length - 2); } - paramDict[key] = value; + return value.ToString(); } - - paramDict.TryGetValue(parameterName, out var result); - return result; + + return null; } } \ No newline at end of file From 0857b947bb8f726a5c82049596423da90b4ccf81 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Wed, 7 May 2025 23:49:06 -0700 Subject: [PATCH 122/128] Update AuthorizationHelpers.cs --- .../Authentication/AuthorizationHelpers.cs | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index 181e198e..faba003c 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -230,45 +230,35 @@ public async Task ExtractProtectedResourceMetadata( /// The value of the parameter, or null if not found. private static string? ParseWwwAuthenticateParameters(string parameters, string parameterName) { - // More efficient parameter parsing that reduces allocations - ReadOnlySpan parametersSpan = parameters.AsSpan(); - ReadOnlySpan searchParam = parameterName.AsSpan(); - - // Fast path for common case - direct parameter search - int paramNameIndex = parametersSpan.IndexOf(searchParam, StringComparison.OrdinalIgnoreCase); - if (paramNameIndex == -1) + if (!parameters.Contains(parameterName, StringComparison.OrdinalIgnoreCase)) { return null; } - // Use span-based parsing to reduce allocations var parts = parameters.Split(','); foreach (var part in parts) { - ReadOnlySpan trimmedPart = part.AsSpan().Trim(); - int equalsIndex = trimmedPart.IndexOf('='); - - if (equalsIndex <= 0 || equalsIndex == trimmedPart.Length - 1) + int equalsIndex = part.IndexOf('='); + if (equalsIndex <= 0 || equalsIndex == part.Length - 1) { continue; } - ReadOnlySpan key = trimmedPart.Slice(0, equalsIndex).Trim(); - - if (key.IsEmpty || !key.Equals(searchParam, StringComparison.OrdinalIgnoreCase)) + string key = part.Substring(0, equalsIndex).Trim(); + + if (string.IsNullOrEmpty(key) || !string.Equals(key, parameterName, StringComparison.OrdinalIgnoreCase)) { continue; } - - ReadOnlySpan value = trimmedPart.Slice(equalsIndex + 1).Trim(); - - // Remove quotes if present + + string value = part.Substring(equalsIndex + 1).Trim(); + if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') { - value = value.Slice(1, value.Length - 2); + value = value.Substring(1, value.Length - 2); } - return value.ToString(); + return value; } return null; From a40325dfeb77f0ce55490864cff6aad57a5579a7 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 8 May 2025 00:03:33 -0700 Subject: [PATCH 123/128] .NET Standard 2.0 compatibility fix --- .../Authentication/AuthorizationHelpers.cs | 77 ++++++++----------- 1 file changed, 31 insertions(+), 46 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs index faba003c..64c85bb4 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -15,7 +15,7 @@ public class AuthorizationHelpers private static readonly Lazy _defaultHttpClient = new(() => new HttpClient()); /// - /// The common well-known path prefix for resource metadata. + /// The well-known path prefix for resource metadata. /// private static readonly string WellKnownPathPrefix = "/.well-known/"; @@ -82,30 +82,27 @@ private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResou } /// - /// Normalizes a URI for consistent comparison by removing ports and trailing slashes. + /// Normalizes a URI for consistent comparison. /// /// The URI to normalize. /// A normalized string representation of the URI. private static string NormalizeUri(Uri uri) { - // Create a builder that will normalize the URI var builder = new UriBuilder(uri) { - Port = -1 // Always remove port specification regardless of whether it's default or not + Port = -1 // Always remove port }; - // Ensure consistent path representation (remove trailing slash if it's just "/") if (builder.Path == "/") { builder.Path = string.Empty; } - // Remove trailing slash for other paths else if (builder.Path.Length > 1 && builder.Path.EndsWith("/")) { builder.Path = builder.Path.TrimEnd('/'); } - return builder.Uri.ToString().TrimEnd('/'); + return builder.Uri.ToString(); } /// @@ -116,38 +113,27 @@ private static string NormalizeUri(Uri uri) /// Thrown when the URI does not contain a valid well-known path. private Uri ExtractBaseResourceUri(Uri metadataUri) { - // Get the absolute URI path to check for well-known path - string absoluteUriString = metadataUri.AbsoluteUri; + // Check for well-known path + int wellKnownIndex = metadataUri.AbsolutePath.IndexOf(WellKnownPathPrefix, StringComparison.OrdinalIgnoreCase); - // Find the well-known path index directly with string operations - // This avoids the allocation from WellKnownPathPrefix.AsSpan() - int wellKnownIndex = absoluteUriString.IndexOf(WellKnownPathPrefix, StringComparison.OrdinalIgnoreCase); - - // Validate that the URL contains the well-known path - if (wellKnownIndex <= 0) + // Validate the URL contains a valid well-known path + if (wellKnownIndex < 0) { throw new InvalidOperationException( $"Resource metadata URL '{metadataUri}' does not contain a valid well-known path format (/.well-known/)"); } - // Get just the path segment before .well-known directly on the URI - int wellKnownPathIndex = metadataUri.AbsolutePath.IndexOf(WellKnownPathPrefix, StringComparison.OrdinalIgnoreCase); - - // Create a new URI builder using the original scheme and authority + // Create URI with just the base part var baseUriBuilder = new UriBuilder(metadataUri) { - Path = wellKnownPathIndex > 0 ? metadataUri.AbsolutePath.Substring(0, wellKnownPathIndex) : "/", + Path = wellKnownIndex > 0 ? metadataUri.AbsolutePath.Substring(0, wellKnownIndex) : "/", Fragment = string.Empty, - Query = string.Empty + Query = string.Empty, + Port = -1 // Remove port }; - // Ensure the path ends with exactly one slash for consistency - string path = baseUriBuilder.Path; - if (string.IsNullOrEmpty(path)) - { - baseUriBuilder.Path = "/"; - } - else if (!path.EndsWith("/")) + // Ensure path ends with a slash + if (!baseUriBuilder.Path.EndsWith("/")) { baseUriBuilder.Path += "/"; } @@ -230,35 +216,34 @@ public async Task ExtractProtectedResourceMetadata( /// The value of the parameter, or null if not found. private static string? ParseWwwAuthenticateParameters(string parameters, string parameterName) { - if (!parameters.Contains(parameterName, StringComparison.OrdinalIgnoreCase)) + if (parameters.IndexOf(parameterName, StringComparison.OrdinalIgnoreCase) == -1) { return null; } - var parts = parameters.Split(','); - foreach (var part in parts) + foreach (var part in parameters.Split(',')) { - int equalsIndex = part.IndexOf('='); - if (equalsIndex <= 0 || equalsIndex == part.Length - 1) - { - continue; - } + string trimmedPart = part.Trim(); + int equalsIndex = trimmedPart.IndexOf('='); - string key = part.Substring(0, equalsIndex).Trim(); - - if (string.IsNullOrEmpty(key) || !string.Equals(key, parameterName, StringComparison.OrdinalIgnoreCase)) + if (equalsIndex <= 0) { continue; } - - string value = part.Substring(equalsIndex + 1).Trim(); - - if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') + + string key = trimmedPart.Substring(0, equalsIndex).Trim(); + + if (string.Equals(key, parameterName, StringComparison.OrdinalIgnoreCase)) { - value = value.Substring(1, value.Length - 2); + string value = trimmedPart.Substring(equalsIndex + 1).Trim(); + + if (value.StartsWith("\"") && value.EndsWith("\"")) + { + value = value.Substring(1, value.Length - 2); + } + + return value; } - - return value; } return null; From a0cd4ad3dbfa374c586c974d5e7fe18da9ca8dc6 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 8 May 2025 08:46:33 -0700 Subject: [PATCH 124/128] Naming consistency --- ...pAuthorizationExtensions.cs => McpAuthenticationExceptions.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/ModelContextProtocol.AspNetCore/Authentication/{McpAuthorizationExtensions.cs => McpAuthenticationExceptions.cs} (100%) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationExtensions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationExceptions.cs similarity index 100% rename from src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationExtensions.cs rename to src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationExceptions.cs From fe0d8bae911ae9fc22c6a3aa4448d68cc2ffe07d Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Thu, 8 May 2025 08:51:31 -0700 Subject: [PATCH 125/128] Update for consistency --- ...ationProvider.cs => BasicOAuthProvider.cs} | 6 ++--- samples/ProtectedMCPClient/Program.cs | 2 +- .../AuthorizationDelegatingHandler.cs | 26 +++++++++---------- ...nProvider.cs => IMcpCredentialProvider.cs} | 2 +- .../Protocol/Transport/SseClientTransport.cs | 10 +++---- 5 files changed, 23 insertions(+), 23 deletions(-) rename samples/ProtectedMCPClient/{BasicOAuthAuthorizationProvider.cs => BasicOAuthProvider.cs} (98%) rename src/ModelContextProtocol/Authentication/{ITokenProvider.cs => IMcpCredentialProvider.cs} (98%) diff --git a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs b/samples/ProtectedMCPClient/BasicOAuthProvider.cs similarity index 98% rename from samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs rename to samples/ProtectedMCPClient/BasicOAuthProvider.cs index b9b2eed6..544d9c6c 100644 --- a/samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs +++ b/samples/ProtectedMCPClient/BasicOAuthProvider.cs @@ -14,7 +14,7 @@ namespace ProtectedMCPClient; /// caching or any advanced token protection - it acquires a token and server metadata and holds it /// in memory as-is. This is NOT PRODUCTION READY and MUST NOT BE USED IN PRODUCTION. /// -public class BasicOAuthAuthorizationProvider : ITokenProvider +public class BasicOAuthProvider : IMcpCredentialProvider { /// /// The Bearer authentication scheme. @@ -38,7 +38,7 @@ public class BasicOAuthAuthorizationProvider : ITokenProvider private AuthorizationServerMetadata? _authServerMetadata; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The MCP server URL. /// The HTTP client to use for OAuth requests. If null, a default HttpClient will be used. @@ -47,7 +47,7 @@ public class BasicOAuthAuthorizationProvider : ITokenProvider /// OAuth client secret. /// OAuth redirect URI. /// OAuth scopes. - public BasicOAuthAuthorizationProvider( + public BasicOAuthProvider( Uri serverUrl, HttpClient? httpClient, AuthorizationHelpers? authorizationHelpers, diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMCPClient/Program.cs index ee9cae32..c76967bb 100644 --- a/samples/ProtectedMCPClient/Program.cs +++ b/samples/ProtectedMCPClient/Program.cs @@ -25,7 +25,7 @@ static async Task Main(string[] args) // Create the token provider with our custom HttpClient, // letting the AuthorizationHelpers be created automatically - var tokenProvider = new BasicOAuthAuthorizationProvider( + var tokenProvider = new BasicOAuthProvider( new Uri(serverUrl), httpClient, null, // AuthorizationHelpers will be created automatically diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index 65a6081d..a608d8be 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -8,23 +8,23 @@ namespace ModelContextProtocol.Authentication; /// public class AuthorizationDelegatingHandler : DelegatingHandler { - private readonly ITokenProvider _authorizationProvider; + private readonly IMcpCredentialProvider _credentialProvider; private string _currentScheme; private static readonly char[] SchemeSplitDelimiters = { ' ', ',' }; /// /// Initializes a new instance of the class. /// - /// The provider that supplies authentication tokens. - public AuthorizationDelegatingHandler(ITokenProvider authorizationProvider) + /// The provider that supplies authentication tokens. + public AuthorizationDelegatingHandler(IMcpCredentialProvider credentialProvider) { - Throw.IfNull(authorizationProvider); + Throw.IfNull(credentialProvider); - _authorizationProvider = authorizationProvider; + _credentialProvider = credentialProvider; // Select first supported scheme as the default - _currentScheme = _authorizationProvider.SupportedSchemes.FirstOrDefault() ?? - throw new ArgumentException("Authorization provider must support at least one authentication scheme.", nameof(authorizationProvider)); + _currentScheme = _credentialProvider.SupportedSchemes.FirstOrDefault() ?? + throw new ArgumentException("Authorization provider must support at least one authentication scheme.", nameof(credentialProvider)); } /// @@ -65,14 +65,14 @@ private async Task HandleUnauthorizedResponseAsync( string schemeUsed = originalRequest.Headers.Authorization?.Scheme ?? _currentScheme ?? string.Empty; if (!string.IsNullOrEmpty(schemeUsed) && serverSchemes.Contains(schemeUsed) && - _authorizationProvider.SupportedSchemes.Contains(schemeUsed)) + _credentialProvider.SupportedSchemes.Contains(schemeUsed)) { bestSchemeMatch = schemeUsed; } else { // Find the first server scheme that's in our supported set - bestSchemeMatch = serverSchemes.Intersect(_authorizationProvider.SupportedSchemes, StringComparer.OrdinalIgnoreCase).FirstOrDefault(); + bestSchemeMatch = serverSchemes.Intersect(_credentialProvider.SupportedSchemes, StringComparer.OrdinalIgnoreCase).FirstOrDefault(); // If no match was found, either throw an exception or use default if (bestSchemeMatch is null) @@ -81,11 +81,11 @@ private async Task HandleUnauthorizedResponseAsync( { throw new InvalidOperationException( $"No matching authentication scheme found. Server supports: [{string.Join(", ", serverSchemes)}], " + - $"Provider supports: [{string.Join(", ", _authorizationProvider.SupportedSchemes)}]."); + $"Provider supports: [{string.Join(", ", _credentialProvider.SupportedSchemes)}]."); } // If the server didn't specify any schemes, use the provider's default - bestSchemeMatch = _authorizationProvider.SupportedSchemes.FirstOrDefault(); + bestSchemeMatch = _credentialProvider.SupportedSchemes.FirstOrDefault(); } } @@ -93,7 +93,7 @@ private async Task HandleUnauthorizedResponseAsync( if (bestSchemeMatch != null) { // Try to handle the 401 response with the selected scheme - var (handled, recommendedScheme) = await _authorizationProvider.HandleUnauthorizedResponseAsync( + var (handled, recommendedScheme) = await _credentialProvider.HandleUnauthorizedResponseAsync( response, bestSchemeMatch, cancellationToken).ConfigureAwait(false); @@ -174,7 +174,7 @@ private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, strin { if (request.RequestUri != null) { - var token = await _authorizationProvider.GetCredentialAsync(scheme, request.RequestUri, cancellationToken).ConfigureAwait(false); + var token = await _credentialProvider.GetCredentialAsync(scheme, request.RequestUri, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrEmpty(token)) { request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token); diff --git a/src/ModelContextProtocol/Authentication/ITokenProvider.cs b/src/ModelContextProtocol/Authentication/IMcpCredentialProvider.cs similarity index 98% rename from src/ModelContextProtocol/Authentication/ITokenProvider.cs rename to src/ModelContextProtocol/Authentication/IMcpCredentialProvider.cs index e3af2e29..7b28f445 100644 --- a/src/ModelContextProtocol/Authentication/ITokenProvider.cs +++ b/src/ModelContextProtocol/Authentication/IMcpCredentialProvider.cs @@ -4,7 +4,7 @@ namespace ModelContextProtocol.Authentication; /// Defines an interface for providing authentication for requests. /// This is the main extensibility point for authentication in MCP clients. /// -public interface ITokenProvider +public interface IMcpCredentialProvider { /// /// Gets the collection of authentication schemes supported by this provider. diff --git a/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs b/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs index 311f88ef..c5e7274f 100644 --- a/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs +++ b/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs @@ -56,21 +56,21 @@ public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient /// Initializes a new instance of the class with authentication support. /// /// Configuration options for the transport. - /// The authorization provider to use for authentication. + /// The authorization provider to use for authentication. /// Logger factory for creating loggers used for diagnostic output during transport operations. /// Optional. The base message handler to use under the authorization handler. /// If null, a new will be used. This allows for custom HTTP client pipelines (e.g., from HttpClientFactory) - /// to be used in conjunction with the token-based authentication provided by . - public SseClientTransport(SseClientTransportOptions transportOptions, ITokenProvider authorizationProvider, ILoggerFactory? loggerFactory = null, HttpMessageHandler? baseMessageHandler = null) + /// to be used in conjunction with the token-based authentication provided by . + public SseClientTransport(SseClientTransportOptions transportOptions, IMcpCredentialProvider credentialProvider, ILoggerFactory? loggerFactory = null, HttpMessageHandler? baseMessageHandler = null) { Throw.IfNull(transportOptions); - Throw.IfNull(authorizationProvider); + Throw.IfNull(credentialProvider); _options = transportOptions; _loggerFactory = loggerFactory; Name = transportOptions.Name ?? transportOptions.Endpoint.ToString(); - var authHandler = new AuthorizationDelegatingHandler(authorizationProvider) + var authHandler = new AuthorizationDelegatingHandler(credentialProvider) { InnerHandler = baseMessageHandler ?? new HttpClientHandler() }; From 0ff4ac49cf2ca8a5b2e3e2bbc66620d7df8ee73f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Fri, 9 May 2025 17:53:34 -0700 Subject: [PATCH 126/128] Update src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs Co-authored-by: Stephen Halter --- .../Authentication/AuthorizationDelegatingHandler.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index a608d8be..39322fcf 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -79,8 +79,9 @@ private async Task HandleUnauthorizedResponseAsync( { if (serverSchemes.Count > 0) { - throw new InvalidOperationException( - $"No matching authentication scheme found. Server supports: [{string.Join(", ", serverSchemes)}], " + + throw new IOException( + $"The server does not support any of the provided authentication schemes." + $"Server supports: [{string.Join(", ", serverSchemes)}], " + $"Provider supports: [{string.Join(", ", _credentialProvider.SupportedSchemes)}]."); } From 3b46c8d3c47222c7cda5c1f8bfb94597c2d645c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Fri, 9 May 2025 17:53:49 -0700 Subject: [PATCH 127/128] Update src/ModelContextProtocol/Protocol/Transport/SseClientTransportOptions.cs Co-authored-by: Stephen Halter --- .../Protocol/Transport/SseClientTransportOptions.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ModelContextProtocol/Protocol/Transport/SseClientTransportOptions.cs b/src/ModelContextProtocol/Protocol/Transport/SseClientTransportOptions.cs index e4af6db0..b83204ae 100644 --- a/src/ModelContextProtocol/Protocol/Transport/SseClientTransportOptions.cs +++ b/src/ModelContextProtocol/Protocol/Transport/SseClientTransportOptions.cs @@ -1,7 +1,5 @@ namespace ModelContextProtocol.Protocol.Transport; -using ModelContextProtocol.Authentication; - /// /// Provides options for configuring instances. /// From 5980c9ae8cb28510f394e6d7fba871d5ad6b3c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Den=20Delimarsky=20=F0=9F=8C=BA?= <53200638+localden@users.noreply.github.com> Date: Thu, 15 May 2025 11:35:53 -0700 Subject: [PATCH 128/128] Update src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs Co-authored-by: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> --- .../Authentication/AuthorizationDelegatingHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs index 39322fcf..5673f2d2 100644 --- a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -80,7 +80,7 @@ private async Task HandleUnauthorizedResponseAsync( if (serverSchemes.Count > 0) { throw new IOException( - $"The server does not support any of the provided authentication schemes." + $"The server does not support any of the provided authentication schemes." + $"Server supports: [{string.Join(", ", serverSchemes)}], " + $"Provider supports: [{string.Join(", ", _credentialProvider.SupportedSchemes)}]."); }