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/Directory.Packages.props b/Directory.Packages.props index fa70d20a..f6215070 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,9 +4,9 @@ 10.0.0-preview.3.25171.5 9.4.4-preview.1.25259.16 - + @@ -14,33 +14,30 @@ - + - + + - - - - @@ -61,8 +58,8 @@ - - + + diff --git a/ModelContextProtocol.sln b/ModelContextProtocol.sln index 0e4fd721..39fb0f94 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}") = "ProtectedMCPClient", "samples\ProtectedMCPClient\ProtectedMCPClient.csproj", "{CF41BB82-4E3E-5E86-BCB6-0DF3A1B48CF2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedMCPServer", "samples\ProtectedMCPServer\ProtectedMCPServer.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/ProtectedMCPClient/BasicOAuthProvider.cs b/samples/ProtectedMCPClient/BasicOAuthProvider.cs new file mode 100644 index 00000000..544d9c6c --- /dev/null +++ b/samples/ProtectedMCPClient/BasicOAuthProvider.cs @@ -0,0 +1,420 @@ +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Types.Authentication; +using ProtectedMCPClient.Types; +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. 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. +/// +public class BasicOAuthProvider : IMcpCredentialProvider +{ + /// + /// The Bearer authentication scheme. + /// + private const string BearerScheme = "Bearer"; + + 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; + + // Lazy-initialized shared HttpClient for when no client is provided + private static readonly Lazy _defaultHttpClient = new(() => new HttpClient()); + + private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; + + private TokenContainer? _token; + private AuthorizationServerMetadata? _authServerMetadata; + + /// + /// 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. + /// The authorization helpers. + /// OAuth client ID. + /// OAuth client secret. + /// OAuth redirect URI. + /// OAuth scopes. + public BasicOAuthProvider( + Uri serverUrl, + HttpClient? httpClient, + AuthorizationHelpers? authorizationHelpers, + string clientId = "demo-client", + string clientSecret = "", + Uri? redirectUri = null, + IEnumerable? scopes = null) + { + _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); + _httpClient = httpClient ?? _defaultHttpClient.Value; + _authorizationHelpers = authorizationHelpers ?? new AuthorizationHelpers(_httpClient); + + _redirectUri = redirectUri ?? new Uri("http://localhost:8080/callback"); + _scopes = scopes?.ToList() ?? []; + _clientId = clientId; + _clientSecret = clientSecret; + } + + /// + public IEnumerable SupportedSchemes => new[] { BearerScheme }; + + /// + public Task GetCredentialAsync(string scheme, Uri resourceUri, CancellationToken cancellationToken = default) + { + // This provider only supports Bearer tokens + if (scheme != BearerScheme) + { + return Task.FromResult(null); + } + + return GetBearerTokenAsync(cancellationToken); + } + + /// + public async Task HandleUnauthorizedResponseAsync( + HttpResponseMessage response, + string scheme, + CancellationToken cancellationToken = default) + { + // This provider only supports Bearer scheme + if (scheme != BearerScheme) + { + return new McpUnauthorizedResponseResult(false, null); + } + + try + { + // Get the metadata from the challenge using the instance-based AuthorizationHelpers + var resourceMetadata = await _authorizationHelpers.ExtractProtectedResourceMetadata( + response, _serverUrl, cancellationToken); + + if (resourceMetadata?.AuthorizationServers?.Count > 0) + { + // Get auth server metadata + var authServerMetadata = await GetAuthServerMetadataAsync( + resourceMetadata.AuthorizationServers[0], cancellationToken); + + if (authServerMetadata != null) + { + // Store auth server metadata for future refresh operations + _authServerMetadata = authServerMetadata; + + // Do the OAuth flow + var token = await InitiateAuthorizationCodeFlowAsync(authServerMetadata, cancellationToken); + if (token != null) + { + _token = token; + return new McpUnauthorizedResponseResult(true, BearerScheme); + } + } + } + + return new McpUnauthorizedResponseResult(false, null); + } + catch (Exception ex) + { + Console.WriteLine($"Error handling auth challenge: {ex.Message}"); + return new McpUnauthorizedResponseResult(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(); + if (!baseUrl.EndsWith("/")) baseUrl += "/"; + + foreach (var path in new[] { ".well-known/openid-configuration", ".well-known/oauth-authorization-server" }) + { + try + { + 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, _jsonOptions); + + 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) + { + Console.WriteLine($"Error fetching auth server metadata from {path}: {ex.Message}"); + } + } + + return null; + } + + private async Task RefreshTokenAsync(string refreshToken, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) + { + var requestContent = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["client_id"] = _clientId + }); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint) + { + Content = requestContent + }; + + 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) + { + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var tokenResponse = JsonSerializer.Deserialize( + json, _jsonOptions); + + if (tokenResponse != null) + { + tokenResponse.ObtainedAt = DateTimeOffset.UtcNow; + if (string.IsNullOrEmpty(tokenResponse.RefreshToken)) + { + tokenResponse.RefreshToken = refreshToken; + } + + return tokenResponse; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error refreshing token: {ex.Message}"); + } + + return null; + } + + private async Task InitiateAuthorizationCodeFlowAsync( + AuthorizationServerMetadata authServerMetadata, + CancellationToken cancellationToken) + { + var codeVerifier = GenerateCodeVerifier(); + var codeChallenge = GenerateCodeChallenge(codeVerifier); + + var authUrl = BuildAuthorizationUrl(authServerMetadata, codeChallenge); + + var authCode = await GetAuthorizationCodeAsync(authUrl, cancellationToken); + if (string.IsNullOrEmpty(authCode)) return null; + + return await ExchangeCodeForTokenAsync(authServerMetadata, authCode, codeVerifier, cancellationToken); + } + + 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(); + queryParams["response_type"] = "code"; + queryParams["code_challenge"] = codeChallenge; + queryParams["code_challenge_method"] = "S256"; + + if (_scopes.Any()) + { + queryParams["scope"] = string.Join(" ", _scopes); + } + + 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 + { + listener.Start(); + + OpenBrowser(authorizationUrl); + + var context = await listener.GetContextAsync(); + + var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty); + var code = query["code"]; + var error = query["error"]; + + string responseHtml = "

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(); + + if (!string.IsNullOrEmpty(error)) + { + Console.WriteLine($"Auth error: {error}"); + return null; + } + + return code; + } + catch (Exception ex) + { + Console.WriteLine($"Error getting auth code: {ex.Message}"); + return null; + } + finally + { + if (listener.IsListening) listener.Stop(); + } + } + + private async Task ExchangeCodeForTokenAsync( + AuthorizationServerMetadata authServerMetadata, + string authorizationCode, + string codeVerifier, + CancellationToken cancellationToken) + { + var requestContent = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = authorizationCode, + ["redirect_uri"] = _redirectUri.ToString(), + ["client_id"] = _clientId, + ["code_verifier"] = codeVerifier + }); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint) + { + Content = requestContent + }; + + 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) + { + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var tokenResponse = JsonSerializer.Deserialize( + json, _jsonOptions); + + if (tokenResponse != null) + { + tokenResponse.ObtainedAt = DateTimeOffset.UtcNow; + return tokenResponse; + } + } + else + { + Console.WriteLine($"Token exchange failed: {response.StatusCode}"); + var error = await response.Content.ReadAsStringAsync(cancellationToken); + Console.WriteLine($"Error: {error}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Exception during token exchange: {ex.Message}"); + } + + return null; + } + + 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]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + 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 new file mode 100644 index 00000000..c76967bb --- /dev/null +++ b/samples/ProtectedMCPClient/Program.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol.Transport; + +namespace ProtectedMCPClient; + +class Program +{ + static async Task Main(string[] args) + { + Console.WriteLine("Protected MCP Weather Server"); + Console.WriteLine(); + + var serverUrl = "http://localhost:7071/sse"; + + // We can customize a shared HttpClient with a custom handler if desired + var sharedHandler = new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1) + }; + + var httpClient = new HttpClient(sharedHandler); + + // Create the token provider with our custom HttpClient, + // letting the AuthorizationHelpers be created automatically + var tokenProvider = new BasicOAuthProvider( + new Uri(serverUrl), + 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"] + ); + + Console.WriteLine(); + Console.WriteLine($"Connecting to weather server at {serverUrl}..."); + + try + { + var transportOptions = new SseClientTransportOptions + { + Endpoint = new Uri(serverUrl), + Name = "Secure Weather Client" + }; + + // 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(); + if (tools.Count == 0) + { + Console.WriteLine("No tools available on the server."); + return; + } + + Console.WriteLine($"Found {tools.Count} tools on the server."); + Console.WriteLine(); + + if (tools.Any(t => t.Name == "GetAlerts")) + { + Console.WriteLine("Calling GetAlerts tool..."); + + var result = await client.CallToolAsync( + "GetAlerts", + new Dictionary { { "state", "WA" } } + ); + + Console.WriteLine("Result: " + result.Content[0].Text); + Console.WriteLine(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + if (ex.InnerException != null) + { + Console.WriteLine($"Inner error: {ex.InnerException.Message}"); + } + + #if DEBUG + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + #endif + } + + Console.WriteLine("Press any key to exit..."); + Console.ReadKey(); + } +} \ No newline at end of file diff --git a/samples/ProtectedMCPClient/ProtectedMCPClient.csproj b/samples/ProtectedMCPClient/ProtectedMCPClient.csproj new file mode 100644 index 00000000..cc60ff80 --- /dev/null +++ b/samples/ProtectedMCPClient/ProtectedMCPClient.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + \ No newline at end of file diff --git a/samples/ProtectedMCPClient/Types/AuthorizationServerMetadata.cs b/samples/ProtectedMCPClient/Types/AuthorizationServerMetadata.cs new file mode 100644 index 00000000..c5fd6253 --- /dev/null +++ b/samples/ProtectedMCPClient/Types/AuthorizationServerMetadata.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Types.Authentication; + +/// +/// 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; } +} \ No newline at end of file diff --git a/samples/ProtectedMCPClient/Types/TokenContainer.cs b/samples/ProtectedMCPClient/Types/TokenContainer.cs new file mode 100644 index 00000000..34deb859 --- /dev/null +++ b/samples/ProtectedMCPClient/Types/TokenContainer.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace ProtectedMCPClient.Types; + +/// +/// Represents a token response from the OAuth server. +/// +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. + /// + [JsonIgnore] + public DateTimeOffset ObtainedAt { get; set; } + + /// + /// Gets the timestamp when the token expires, calculated from ObtainedAt and ExpiresIn. + /// + [JsonIgnore] + public DateTimeOffset ExpiresAt => ObtainedAt.AddSeconds(ExpiresIn); +} diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMCPServer/Program.cs new file mode 100644 index 00000000..54d21a9d --- /dev/null +++ b/samples/ProtectedMCPServer/Program.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using ModelContextProtocol.AspNetCore.Authentication; +using ModelContextProtocol.Authentication; +using ProtectedMCPServer.Tools; +using System.Net.Http.Headers; +using System.Security.Claims; + +var builder = WebApplication.CreateBuilder(args); + +var serverUrl = "http://localhost:7071/"; +var tenantId = "a2213e1c-e51e-4304-9a0d-effe57f31655"; +var instance = "https://login.microsoftonline.com/"; + +builder.Services.AddAuthentication(options => +{ + options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.Authority = $"{instance}{tenantId}/v2.0"; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidAudience = "167b4284-3f92-4436-92ed-38b38f83ae08", + ValidIssuer = $"{instance}{tenantId}/v2.0", + NameClaimType = "name", + RoleClaimType = "roles" + }; + + options.MetadataAddress = $"{instance}{tenantId}/v2.0/.well-known/openid-configuration"; + + 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.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([ + "api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read" + ]); + + return metadata; + }; +}); + +builder.Services.AddAuthorization(); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddMcpServer() +.WithTools() +.WithHttpTransport(); + +// Configure HttpClientFactory for weather.gov API +builder.Services.AddHttpClient("WeatherApi", client => +{ + client.BaseAddress = new Uri("https://api.weather.gov"); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0")); +}); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +// Use the default MCP policy name that we've configured +app.MapMcp().RequireAuthorization(); + +Console.WriteLine($"Starting MCP server with authorization at {serverUrl}"); +Console.WriteLine($"PRM Document URL: {serverUrl}.well-known/oauth-protected-resource"); +Console.WriteLine("Press Ctrl+C to stop the server"); + +app.Run(serverUrl); diff --git a/samples/ProtectedMCPServer/Properties/launchSettings.json b/samples/ProtectedMCPServer/Properties/launchSettings.json new file mode 100644 index 00000000..03646532 --- /dev/null +++ b/samples/ProtectedMCPServer/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ProtectedMCPServer": { + "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/ProtectedMCPServer/ProtectedMCPServer.csproj b/samples/ProtectedMCPServer/ProtectedMCPServer.csproj new file mode 100644 index 00000000..e3d680fd --- /dev/null +++ b/samples/ProtectedMCPServer/ProtectedMCPServer.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + \ No newline at end of file diff --git a/samples/ProtectedMCPServer/Tools/HttpClientExt.cs b/samples/ProtectedMCPServer/Tools/HttpClientExt.cs new file mode 100644 index 00000000..f7b2b549 --- /dev/null +++ b/samples/ProtectedMCPServer/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/ProtectedMCPServer/Tools/WeatherTools.cs b/samples/ProtectedMCPServer/Tools/WeatherTools.cs new file mode 100644 index 00000000..7c8c0851 --- /dev/null +++ b/samples/ProtectedMCPServer/Tools/WeatherTools.cs @@ -0,0 +1,67 @@ +using ModelContextProtocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; + +namespace ProtectedMCPServer.Tools; + +[McpServerToolType] +public sealed class WeatherTools +{ + private readonly IHttpClientFactory _httpClientFactory; + + public WeatherTools(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + [McpServerTool, Description("Get weather alerts for a US state.")] + public async Task GetAlerts( + [Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state) + { + var client = _httpClientFactory.CreateClient("WeatherApi"); + using var jsonDocument = await client.ReadJsonDocumentAsync($"/alerts/active/area/{state}"); + var jsonElement = jsonDocument.RootElement; + var alerts = jsonElement.GetProperty("features").EnumerateArray(); + + if (!alerts.Any()) + { + return "No active alerts for this state."; + } + + return string.Join("\n--\n", alerts.Select(alert => + { + JsonElement properties = alert.GetProperty("properties"); + return $""" + Event: {properties.GetProperty("event").GetString()} + Area: {properties.GetProperty("areaDesc").GetString()} + Severity: {properties.GetProperty("severity").GetString()} + Description: {properties.GetProperty("description").GetString()} + Instruction: {properties.GetProperty("instruction").GetString()} + """; + })); + } + + [McpServerTool, Description("Get weather forecast for a location.")] + public async Task GetForecast( + [Description("Latitude of the location.")] double latitude, + [Description("Longitude of the location.")] double longitude) + { + var client = _httpClientFactory.CreateClient("WeatherApi"); + var pointUrl = string.Create(CultureInfo.InvariantCulture, $"/points/{latitude},{longitude}"); + using var jsonDocument = await client.ReadJsonDocumentAsync(pointUrl); + var forecastUrl = jsonDocument.RootElement.GetProperty("properties").GetProperty("forecast").GetString() + ?? throw new Exception($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}"); + + using var forecastDocument = await client.ReadJsonDocumentAsync(forecastUrl); + var periods = forecastDocument.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray(); + + return string.Join("\n---\n", periods.Select(period => $""" + {period.GetProperty("name").GetString()} + Temperature: {period.GetProperty("temperature").GetInt32()}°F + Wind: {period.GetProperty("windSpeed").GetString()} {period.GetProperty("windDirection").GetString()} + Forecast: {period.GetProperty("detailedForecast").GetString()} + """)); + } +} diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationDefaults.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationDefaults.cs new file mode 100644 index 00000000..4c720c65 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationDefaults.cs @@ -0,0 +1,17 @@ +namespace ModelContextProtocol.AspNetCore.Authentication; + +/// +/// 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/Authentication/McpAuthenticationEvents.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationEvents.cs new file mode 100644 index 00000000..7a7eb29b --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationEvents.cs @@ -0,0 +1,9 @@ +namespace ModelContextProtocol.AspNetCore.Authentication +{ + /// + /// Represents the authentication events for Model Context Protocol. + /// + public class McpAuthenticationEvents + { + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationExceptions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationExceptions.cs new file mode 100644 index 00000000..96f200da --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationExceptions.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Authentication; +using ModelContextProtocol.AspNetCore.Authentication; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for adding MCP authorization support to ASP.NET Core applications. +/// +public static class McpAuthenticationExtensions +{ + /// + /// 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 AddMcp( + this AuthenticationBuilder builder, + Action? configureOptions = null) + { + return AddMcp( + builder, + McpAuthenticationDefaults.AuthenticationScheme, + McpAuthenticationDefaults.DisplayName, + 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) + { + if (configureOptions != null) + { + if (authenticationScheme == McpAuthenticationDefaults.AuthenticationScheme) + { + builder.Services.Configure(configureOptions); + } + else + { + builder.Services.Configure(authenticationScheme, configureOptions); + } + } + + return builder.AddScheme( + authenticationScheme, + displayName, + options => { }); // No-op to avoid overriding + } +} diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs new file mode 100644 index 00000000..e0d87687 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -0,0 +1,152 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Utils.Json; +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Authentication; + +/// +/// Authentication handler for MCP protocol that adds resource metadata to challenge responses +/// and handles resource metadata endpoint requests. +/// +public class McpAuthenticationHandler : AuthenticationHandler, IAuthenticationRequestHandler +{ + private readonly IOptionsMonitor _optionsMonitor; + private string _resourceMetadataPath; + + /// + /// Initializes a new instance of the class. + /// + public McpAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + _optionsMonitor = options; + _resourceMetadataPath = options.CurrentValue.ResourceMetadataUri.ToString(); + } + + /// + public async Task HandleRequestAsync() + { + // Check if the request is for the resource metadata endpoint + string requestPath = Request.Path.Value ?? string.Empty; + + // If the path doesn't match, let the request continue through the pipeline + if (!string.Equals(requestPath, _resourceMetadataPath, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Use the request's cancellation token if available + var cancellationToken = Request.HttpContext.RequestAborted; + await HandleResourceMetadataRequestAsync(cancellationToken); + 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 the options have changed, update the cached path + string currentPath = resourceMetadataUri.ToString(); + if (_resourceMetadataPath != currentPath) + { + _resourceMetadataPath = currentPath; + } + + if (resourceMetadataUri.IsAbsoluteUri) + { + return resourceMetadataUri.ToString(); + } + + // For relative URIs, combine with the base URL + string baseUrl = GetBaseUrl(); + + 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. + /// + /// 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; + var resourceMetadata = options.GetResourceMetadata(Request.HttpContext); + + // 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 + }; + + Response.StatusCode = StatusCodes.Status200OK; + Response.ContentType = "application/json"; + + var json = JsonSerializer.Serialize( + metadata, + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))); + + return Response.WriteAsync(json, cancellationToken); + } + + /// + protected override async Task HandleAuthenticateAsync() + { + // 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(); + } + + /// + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + // Get the absolute URI for the resource metadata + string rawPrmDocumentUri = GetAbsoluteResourceMetadataUri(); + + // Initialize properties if null + properties ??= new AuthenticationProperties(); + + // Store the resource_metadata in properties in case other handlers need it + properties.Items["resource_metadata"] = rawPrmDocumentUri; + + // Add the WWW-Authenticate header with Bearer scheme and resource metadata + string headerValue = $"Bearer realm=\"{Scheme.Name}\", resource_metadata=\"{rawPrmDocumentUri}\""; + Response.Headers.Append("WWW-Authenticate", headerValue); + + return base.HandleChallengeAsync(properties); + } +} diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs new file mode 100644 index 00000000..6434fe06 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.Authentication; + +namespace ModelContextProtocol.AspNetCore.Authentication; + +/// +/// Options for the MCP authentication handler. +/// +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() + { + base.ForwardAuthenticate = "Bearer"; + ResourceMetadataUri = DefaultResourceMetadataUri; + _resourceMetadata = new ProtectedResourceMetadata() { Resource = new Uri("http://localhost") }; + Events = new McpAuthenticationEvents(); + } + + /// + /// 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. + /// + /// + /// 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 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. + /// Setting this property will automatically update the + /// to return this static instance. + /// + public ProtectedResourceMetadata ResourceMetadata + { + get => _resourceMetadata; + set + { + _resourceMetadata = value ?? new ProtectedResourceMetadata() { Resource = new Uri("http://localhost") }; + // 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. + /// + /// + /// 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? ProtectedResourceMetadataProvider + { + 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() { Resource = new Uri("http://localhost") }; + 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) + { + ProtectedResourceMetadataProvider = provider ?? throw new ArgumentNullException(nameof(provider)); + return this; + } + + /// + /// 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) + { + var provider = _resourceMetadataProvider; + + return provider != null + ? provider(context) + : _resourceMetadata; + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs new file mode 100644 index 00000000..c0d6985a --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthorizationPolicyExtensions.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Authorization; +using ModelContextProtocol.AspNetCore.Authentication; + +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); + + configurePolicy?.Invoke(policyBuilder); + + options.AddPolicy(policyName, policyBuilder.Build()); + + 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); + } + + string[] allSchemes = [McpAuthenticationDefaults.AuthenticationScheme, ..additionalSchemes]; + + var policyBuilder = new AuthorizationPolicyBuilder(allSchemes) + .RequireAuthenticatedUser(); + + configurePolicy?.Invoke(policyBuilder); + + options.AddPolicy(policyName, policyBuilder.Build()); + + return options; + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs new file mode 100644 index 00000000..39322fcf --- /dev/null +++ b/src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs @@ -0,0 +1,185 @@ +using ModelContextProtocol.Utils; +using System.Net.Http.Headers; + +namespace ModelContextProtocol.Authentication; + +/// +/// A delegating handler that adds authentication tokens to requests and handles 401 responses. +/// +public class AuthorizationDelegatingHandler : DelegatingHandler +{ + 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(IMcpCredentialProvider credentialProvider) + { + Throw.IfNull(credentialProvider); + + _credentialProvider = credentialProvider; + + // Select first supported scheme as the default + _currentScheme = _credentialProvider.SupportedSchemes.FirstOrDefault() ?? + throw new ArgumentException("Authorization provider must support at least one authentication scheme.", nameof(credentialProvider)); + } + + /// + /// Sends an HTTP request with authentication handling. + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Headers.Authorization == null) + { + await AddAuthorizationHeaderAsync(request, _currentScheme, cancellationToken).ConfigureAwait(false); + } + + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + 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 + 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 (!string.IsNullOrEmpty(schemeUsed) && + serverSchemes.Contains(schemeUsed) && + _credentialProvider.SupportedSchemes.Contains(schemeUsed)) + { + bestSchemeMatch = schemeUsed; + } + else + { + // Find the first server scheme that's in our supported set + bestSchemeMatch = serverSchemes.Intersect(_credentialProvider.SupportedSchemes, StringComparer.OrdinalIgnoreCase).FirstOrDefault(); + + // If no match was found, either throw an exception or use default + if (bestSchemeMatch is null) + { + if (serverSchemes.Count > 0) + { + 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)}]."); + } + + // If the server didn't specify any schemes, use the provider's default + bestSchemeMatch = _credentialProvider.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 _credentialProvider.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."); + } + + _currentScheme = recommendedScheme ?? bestSchemeMatch; + + var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri) + { + 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)) + { + 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 + + // 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 + } + + /// + /// Extracts the authentication schemes that the server supports from the WWW-Authenticate headers. + /// + private static HashSet ExtractServerSupportedSchemes(HttpResponseMessage response) + { + var serverSchemes = new HashSet(StringComparer.OrdinalIgnoreCase); + + 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(SchemeSplitDelimiters, StringSplitOptions.RemoveEmptyEntries)[0]; + serverSchemes.Add(scheme); + } + } + + return serverSchemes; + } + + /// + /// Adds an authorization header to the request. + /// + private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, string scheme, CancellationToken cancellationToken) + { + if (request.RequestUri != null) + { + var token = await _credentialProvider.GetCredentialAsync(scheme, request.RequestUri, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(token)) + { + request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token); + } + } + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs new file mode 100644 index 00000000..64c85bb4 --- /dev/null +++ b/src/ModelContextProtocol/Authentication/AuthorizationHelpers.cs @@ -0,0 +1,251 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Utils.Json; +using System.Text.Json; + +namespace ModelContextProtocol.Authentication; + +/// +/// Provides utility methods for handling authentication in MCP clients. +/// +public class AuthorizationHelpers +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private static readonly Lazy _defaultHttpClient = new(() => new HttpClient()); + + /// + /// The well-known path prefix for resource metadata. + /// + private static readonly string WellKnownPathPrefix = "/.well-known/"; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client to use for requests. If null, a default HttpClient will be used. + /// 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; + } + + /// + /// 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 async Task FetchProtectedResourceMetadataAsync( + Uri metadataUrl, + CancellationToken cancellationToken = default) + { + try + { + var request = new HttpRequestMessage(HttpMethod.Get, metadataUrl); + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + using var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(content, + McpJsonUtilities.JsonContext.Default.ProtectedResourceMetadata, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to fetch protected resource metadata from {metadataUrl}"); + return null; + } + } + + /// + /// 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 exactly matches the server, otherwise false. + private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri resourceLocation) + { + if (protectedResourceMetadata.Resource == null || resourceLocation == null) + { + return false; + } + + // 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. + /// + /// The URI to normalize. + /// A normalized string representation of the URI. + private static string NormalizeUri(Uri uri) + { + var builder = new UriBuilder(uri) + { + Port = -1 // Always remove port + }; + + if (builder.Path == "/") + { + builder.Path = string.Empty; + } + else if (builder.Path.Length > 1 && builder.Path.EndsWith("/")) + { + builder.Path = builder.Path.TrimEnd('/'); + } + + return builder.Uri.ToString(); + } + + /// + /// 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) + { + // Check for well-known path + int wellKnownIndex = metadataUri.AbsolutePath.IndexOf(WellKnownPathPrefix, StringComparison.OrdinalIgnoreCase); + + // 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/)"); + } + + // Create URI with just the base part + var baseUriBuilder = new UriBuilder(metadataUri) + { + Path = wellKnownIndex > 0 ? metadataUri.AbsolutePath.Substring(0, wellKnownIndex) : "/", + Fragment = string.Empty, + Query = string.Empty, + Port = -1 // Remove port + }; + + // Ensure path ends with a slash + if (!baseUriBuilder.Path.EndsWith("/")) + { + baseUriBuilder.Path += "/"; + } + + return baseUriBuilder.Uri; + } + + /// + /// 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 async Task ExtractProtectedResourceMetadata( + 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.Count == 0) + { + 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 (string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(header.Parameter)) + { + resourceMetadataUrl = ParseWwwAuthenticateParameters(header.Parameter, "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(resourceMetadataUrl); + + 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 expected URI ({urlToValidate})"); + } + + 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. + private static string? ParseWwwAuthenticateParameters(string parameters, string parameterName) + { + if (parameters.IndexOf(parameterName, StringComparison.OrdinalIgnoreCase) == -1) + { + return null; + } + + foreach (var part in parameters.Split(',')) + { + string trimmedPart = part.Trim(); + int equalsIndex = trimmedPart.IndexOf('='); + + if (equalsIndex <= 0) + { + continue; + } + + string key = trimmedPart.Substring(0, equalsIndex).Trim(); + + if (string.Equals(key, parameterName, StringComparison.OrdinalIgnoreCase)) + { + string value = trimmedPart.Substring(equalsIndex + 1).Trim(); + + if (value.StartsWith("\"") && value.EndsWith("\"")) + { + value = value.Substring(1, value.Length - 2); + } + + return value; + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Authentication/IMcpCredentialProvider.cs b/src/ModelContextProtocol/Authentication/IMcpCredentialProvider.cs new file mode 100644 index 00000000..7b28f445 --- /dev/null +++ b/src/ModelContextProtocol/Authentication/IMcpCredentialProvider.cs @@ -0,0 +1,48 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Defines an interface for providing authentication for requests. +/// This is the main extensibility point for authentication in MCP clients. +/// +public interface IMcpCredentialProvider +{ + /// + /// Gets the collection of authentication schemes supported by this provider. + /// + /// + /// + /// 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. + /// + /// + IEnumerable SupportedSchemes { get; } + + /// + /// 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 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. + /// + /// 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 HandleUnauthorizedResponseAsync( + HttpResponseMessage response, + string scheme, + CancellationToken cancellationToken = default); +} \ No newline at end of file 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 diff --git a/src/ModelContextProtocol/Authentication/ProtectedResourceMetadata.cs b/src/ModelContextProtocol/Authentication/ProtectedResourceMetadata.cs new file mode 100644 index 00000000..772be5dc --- /dev/null +++ b/src/ModelContextProtocol/Authentication/ProtectedResourceMetadata.cs @@ -0,0 +1,155 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Authentication; + +/// +/// Represents the resource metadata for OAuth authorization as defined in RFC 9396. +/// Defined by RFC 9728. +/// +public class ProtectedResourceMetadata +{ + /// + /// Initializes a new instance of the class. + /// + public ProtectedResourceMetadata() + { + AuthorizationServers = []; + BearerMethodsSupported = []; + ScopesSupported = []; + } + + /// + /// The resource URI. + /// + /// + /// REQUIRED. The protected resource's resource identifier. + /// + [JsonPropertyName("resource")] + public required Uri Resource { get; init; } + + /// + /// 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; } + + /// + /// 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; } + + /// + /// 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; } + + /// + /// 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/Protocol/Transport/SseClientTransport.cs b/src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs index 1b286557..c5e7274f 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,33 @@ 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. + /// 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, IMcpCredentialProvider credentialProvider, ILoggerFactory? loggerFactory = null, HttpMessageHandler? baseMessageHandler = null) + { + Throw.IfNull(transportOptions); + Throw.IfNull(credentialProvider); + + _options = transportOptions; + _loggerFactory = loggerFactory; + Name = transportOptions.Name ?? transportOptions.Endpoint.ToString(); + + var authHandler = new AuthorizationDelegatingHandler(credentialProvider) + { + InnerHandler = baseMessageHandler ?? new HttpClientHandler() + }; + + _httpClient = new HttpClient(authHandler); + _ownsHttpClient = true; + } + /// public string Name { get; } diff --git a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs index 625b558f..0c1d77c4 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.Authentication; using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Protocol.Types; using System.Diagnostics.CodeAnalysis; @@ -123,6 +124,8 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(UnsubscribeRequestParams))] [JsonSerializable(typeof(IReadOnlyDictionary))] + [JsonSerializable(typeof(ProtectedResourceMetadata))] + // Primitive types for use in consuming AIFunctions [JsonSerializable(typeof(string))] [JsonSerializable(typeof(byte))] diff --git a/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs index 4efe5ec5..6ea75162 100644 --- a/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs @@ -34,7 +34,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); }