diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index a70e3e310..654d51b4b 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -42,6 +42,8 @@ + + diff --git a/samples/EntraProtectedMcpClient/Configuration/EntraIdConfiguration.cs b/samples/EntraProtectedMcpClient/Configuration/EntraIdConfiguration.cs new file mode 100644 index 000000000..da07a647d --- /dev/null +++ b/samples/EntraProtectedMcpClient/Configuration/EntraIdConfiguration.cs @@ -0,0 +1,14 @@ +namespace EntraProtectedMcpClient.Configuration; + +public sealed class EntraIdConfiguration +{ + public const string SectionName = "EntraId"; + + public required string TenantId { get; set; } + public required string ClientId { get; set; } + public required string ClientSecret { get; set; } + public required string ServerClientId { get; set; } + public required string RedirectUri { get; set; } + public required string Scope { get; set; } + public string ResponseMode { get; set; } = "query"; +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpClient/Configuration/McpClientConfiguration.cs b/samples/EntraProtectedMcpClient/Configuration/McpClientConfiguration.cs new file mode 100644 index 000000000..c813a50f0 --- /dev/null +++ b/samples/EntraProtectedMcpClient/Configuration/McpClientConfiguration.cs @@ -0,0 +1,8 @@ +namespace EntraProtectedMcpClient.Configuration; + +public sealed class McpClientConfiguration +{ + public const string SectionName = "McpServer"; + + public required string Url { get; set; } +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpClient/Configuration/SecuredSpoSiteConfiguration.cs b/samples/EntraProtectedMcpClient/Configuration/SecuredSpoSiteConfiguration.cs new file mode 100644 index 000000000..7d17f4bb1 --- /dev/null +++ b/samples/EntraProtectedMcpClient/Configuration/SecuredSpoSiteConfiguration.cs @@ -0,0 +1,8 @@ +namespace EntraProtectedMcpClient.Configuration; + +public sealed class SecuredSpoSiteConfiguration +{ + public const string SectionName = "SecuredSpoSite"; + + public string Url { get; set; } = "https://docs.microsoft.com/"; +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpClient/EntraProtectedMcpClient.csproj b/samples/EntraProtectedMcpClient/EntraProtectedMcpClient.csproj new file mode 100644 index 000000000..1596a150e --- /dev/null +++ b/samples/EntraProtectedMcpClient/EntraProtectedMcpClient.csproj @@ -0,0 +1,29 @@ + + + + Exe + net9.0 + enable + enable + entra-mcp-client-secrets + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + \ No newline at end of file diff --git a/samples/EntraProtectedMcpClient/Program.cs b/samples/EntraProtectedMcpClient/Program.cs new file mode 100644 index 000000000..d44a6c203 --- /dev/null +++ b/samples/EntraProtectedMcpClient/Program.cs @@ -0,0 +1,319 @@ +using EntraProtectedMcpClient.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Web; + +var builder = Host.CreateApplicationBuilder(args); + +var configuration = builder.Configuration + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json", optional: true) + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + +// Bind configuration sections to strongly typed objects +var mcpConfig = configuration.GetSection(McpClientConfiguration.SectionName).Get(); +var entraConfig = configuration.GetSection(EntraIdConfiguration.SectionName).Get(); +var spoConfig = configuration.GetSection(SecuredSpoSiteConfiguration.SectionName).Get(); + +// Validate required configuration +ValidateConfiguration(mcpConfig, entraConfig, spoConfig); + +// Display startup information +DisplayStartupInfo(mcpConfig!, entraConfig!, spoConfig!); + +// Create the Mcp Client +var client = await CreateMcpClient(mcpConfig!, entraConfig!); + +var tools = await client.ListToolsAsync(); + +DisplayTools(tools); + +await CallWeatherTool(tools, client); +await CallGraphTool(tools, client); +await CallSpoTool(spoConfig!, tools, client); + + +/// +/// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser. +/// This implementation demonstrates how SDK consumers can provide their own authorization flow. +/// +/// The authorization URL to open in the browser. +/// The redirect URI where the authorization code will be sent. +/// The cancellation token. +/// The authorization code extracted from the callback, or null if the operation failed. +static async Task HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) +{ + Console.WriteLine("Starting OAuth authorization flow..."); + Console.WriteLine($"Opening browser to Microsoft Entra ID: {authorizationUrl}"); + + var listenerPrefix = redirectUri.GetLeftPart(UriPartial.Authority); + if (!listenerPrefix.EndsWith("/")) listenerPrefix += "/"; + + using var listener = new HttpListener(); + listener.Prefixes.Add(listenerPrefix); + + try + { + listener.Start(); + Console.WriteLine($"Listening for OAuth callback on: {listenerPrefix}"); + + OpenBrowser(authorizationUrl); + + var context = await listener.GetContextAsync(); + var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty); + var code = query["code"]; + var error = query["error"]; + var errorDescription = query["error_description"]; + + 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}"); + if (!string.IsNullOrEmpty(errorDescription)) + { + Console.WriteLine($"Error description: {errorDescription}"); + } + return null; + } + + if (string.IsNullOrEmpty(code)) + { + Console.WriteLine("No authorization code received"); + return null; + } + + Console.WriteLine("Authorization code received successfully from Microsoft Entra ID."); + return code; + } + catch (Exception ex) + { + Console.WriteLine($"Error getting auth code: {ex.Message}"); + return null; + } + finally + { + if (listener.IsListening) listener.Stop(); + } +} + +/// +/// Opens the specified URL in the default browser. +/// +/// The URL to open. +static void OpenBrowser(Uri url) +{ + // Validate the URI scheme - only allow safe protocols + if (url.Scheme != Uri.UriSchemeHttp && url.Scheme != Uri.UriSchemeHttps) + { + Console.WriteLine($"Error: Only HTTP and HTTPS URLs are allowed."); + return; + } + + try + { + var psi = new ProcessStartInfo + { + FileName = url.ToString(), + UseShellExecute = true + }; + Process.Start(psi); + } + catch (Exception ex) + { + Console.WriteLine($"Error opening browser: {ex.Message}"); + Console.WriteLine($"Please manually open this URL: {url}"); + } +} + +/// +/// Validates the required configuration sections and throws exceptions if any required values are missing. +/// +/// The MCP client configuration containing server connection details. +/// The Microsoft Entra ID configuration containing authentication details. +/// The secured SharePoint site configuration containing site URL. +/// Thrown when any required configuration value is missing or invalid. +static void ValidateConfiguration(McpClientConfiguration? mcpConfig, EntraIdConfiguration? entraConfig, SecuredSpoSiteConfiguration? spoConfig) +{ + if (mcpConfig?.Url is null) + { + throw new InvalidOperationException("McpServer:Url configuration is missing."); + } + + if (entraConfig is null) + { + throw new InvalidOperationException("EntraId configuration section is missing."); + } + + if (string.IsNullOrEmpty(entraConfig.ClientId)) + { + throw new InvalidOperationException("EntraId:ClientId configuration is missing."); + } + + if (string.IsNullOrEmpty(entraConfig.ClientSecret)) + { + throw new InvalidOperationException("EntraId:ClientSecret configuration is required. Consider using user secrets or environment variables for production."); + } + + if (string.IsNullOrEmpty(spoConfig?.Url)) + { + throw new InvalidOperationException("SecuredSpoSite:Url configuration is missing."); + } +} + +/// +/// Displays startup information to the console, including server URLs and authentication details. +/// +/// The MCP client configuration containing server connection details. +/// The Microsoft Entra ID configuration containing authentication details. +/// The secured SharePoint site configuration containing site URL. +static void DisplayStartupInfo(McpClientConfiguration mcpConfig, EntraIdConfiguration entraConfig, SecuredSpoSiteConfiguration spoConfig) +{ + Console.WriteLine("Protected MCP Client"); + Console.WriteLine($"Connecting to MCP server at {mcpConfig.Url}..."); + Console.WriteLine($"Using Microsoft Entra ID tenant: {entraConfig.TenantId}"); + Console.WriteLine($"Client ID: {entraConfig.ClientId}"); + Console.WriteLine($"Secured SharePoint Site URL: {spoConfig.Url}"); + Console.WriteLine("Press Ctrl+C to stop the server"); +} + +/// +/// Creates and configures an MCP client with OAuth authentication for secure server communication. +/// +/// The MCP client configuration containing server connection details. +/// The Microsoft Entra ID configuration containing OAuth authentication details. +/// A task that represents the asynchronous operation. The task result contains the configured . +static async Task CreateMcpClient(McpClientConfiguration mcpConfig, EntraIdConfiguration entraConfig) +{ + // We can customize a shared HttpClient with a custom handler if desired + var sharedHandler = new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1) + }; + var httpClient = new HttpClient(sharedHandler); + + var consoleLoggerFactory = LoggerFactory.Create(builder => + { + builder.AddConsole(); + }); + + var transport = new HttpClientTransport(new() + { + Endpoint = new Uri(mcpConfig.Url), + Name = "Secure MCP Client", + OAuth = new() + { + ClientId = entraConfig.ClientId, + ClientSecret = entraConfig.ClientSecret, + RedirectUri = new Uri(entraConfig.RedirectUri), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + Scopes = [ + $"api://{entraConfig.ServerClientId}/{entraConfig.Scope}" + ], + + AdditionalAuthorizationParameters = new Dictionary + { + ["tenant"] = entraConfig.TenantId, + ["response_mode"] = "query" + } + } + }, httpClient, consoleLoggerFactory); + + return await McpClient.CreateAsync(transport, loggerFactory: consoleLoggerFactory); +} + +/// +/// Displays information about the available tools on the MCP server. +/// +/// The list of tools retrieved from the MCP server. +static void DisplayTools(IList tools) +{ + if (tools.Count == 0) + { + Console.WriteLine("No tools available on the server."); + return; + } + + Console.WriteLine($"Found {tools.Count} tools on the server."); + Console.WriteLine(); +} + +/// +/// Calls the weather alerts tool if available on the server and displays the result. +/// +/// The list of available tools from the MCP server. +/// The MCP client instance used to invoke the tool. +/// A task that represents the asynchronous tool invocation operation. +static async Task CallWeatherTool(IList tools, McpClient client) +{ + if (tools.Any(t => t.Name == "get_alerts")) + { + Console.WriteLine("Calling get_alerts tool..."); + + var result = await client.CallToolAsync( + "get_alerts", + new Dictionary { { "state", "WA" } } + ); + + Console.WriteLine("Result: " + ((TextContentBlock)result.Content[0]).Text); + Console.WriteLine(); + } +} + +/// +/// Calls the Microsoft Graph hello tool if available on the server and displays the result. +/// +/// The list of available tools from the MCP server. +/// The MCP client instance used to invoke the tool. +/// A task that represents the asynchronous tool invocation operation. +static async Task CallGraphTool(IList tools, McpClient client) +{ + if (tools.Any(t => t.Name == "hello")) + { + Console.WriteLine("Calling Hello tool..."); + + var result = await client.CallToolAsync("hello"); + + Console.WriteLine("Result: " + ((TextContentBlock)result.Content[0]).Text); + Console.WriteLine(); + } +} + +/// +/// Calls the SharePoint site information tool if available on the server and displays the result. +/// +/// The SharePoint site configuration containing the site URL. +/// The list of available tools from the MCP server. +/// The MCP client instance used to invoke the tool. +/// A task that represents the asynchronous tool invocation operation. +static async Task CallSpoTool(SecuredSpoSiteConfiguration spoConfig, IList tools, McpClient client) +{ + if (tools.Any(t => t.Name == "get_site_info")) + { + Console.WriteLine("Calling get_site_info tool..."); + var result = await client.CallToolAsync( + "get_site_info", + new Dictionary + { + { "siteUrl", spoConfig.Url } + } + ); + Console.WriteLine("Result: " + ((TextContentBlock)result.Content[0]).Text); + Console.WriteLine(); + } +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpClient/README.md b/samples/EntraProtectedMcpClient/README.md new file mode 100644 index 000000000..0c8eb2ad3 --- /dev/null +++ b/samples/EntraProtectedMcpClient/README.md @@ -0,0 +1,194 @@ +# Protected MCP Client Sample with Microsoft Entra ID + +This sample demonstrates how to create an MCP client that connects to a protected MCP server using Microsoft Entra ID (Azure AD) OAuth 2.0 authentication. The client implements a custom OAuth authorization flow with browser-based authentication and showcases externalized configuration management. + +## Overview + +The EntraProtectedMcpClient sample shows how to: +- Connect to an OAuth-protected MCP server using Microsoft Entra ID +- Handle OAuth 2.0 authorization code flow with PKCE +- Use custom authorization redirect handling with local HTTP listener +- Call protected MCP tools with authentication +- Manage sensitive data using user secrets +- Test access to Graph and SharePoint + +## Features + +- **Microsoft Entra ID Integration**: Full OAuth 2.0 authentication with Microsoft identity platform +- **User Secrets**: Secure storage of sensitive information during development +- **Graph and SharePoint Access**: Test authentication through MS Graph and SharePoint +- **Comprehensive Logging**: Structured logging with configurable levels + +## Prerequisites + +- .NET 9.0 or later +- Microsoft Entra ID tenant with registered applications +- A running EntraProtectedMcpServer (for MCP services) +- Valid Entra ID credentials configured + +## Configuration + +### Configuration Files + +The client uses a layered configuration approach: + +1. **appsettings.json** - Base configuration (non-sensitive) +2. **appsettings.Development.json** - Development overrides +3. **User Secrets** - Sensitive data (client secrets, credentials) +4. **Environment Variables** - Runtime overrides + +### Configuration Structure + +```json +{ + "McpServer": { + "Url": "http://localhost:7071/" + }, + "EntraId": { + "TenantId": "your-tenant-id", + "ClientId": "your-client-id", + "ServerClientId": "server-app-registration-id", + "RedirectUri": "http://localhost:1179/callback", + "Scope": "user_impersonation", + "ResponseMode": "query" + }, + "SecuredSpoSite": { + "Url": "https://docs.microsoft.com/" + } +} +``` + +### Setting Up User Secrets + +Store sensitive information securely using .NET user secrets: + +```bash +dotnet user-secrets set "McpClient:EntraId:ClientSecret" "your-client-secret" +``` + +### Configuration Classes + +The client uses strongly-typed configuration classes: + +- **`McpClientConfiguration`** - MCP server connection settings +- **`EntraIdConfiguration`** - Microsoft Entra ID authentication settings +- **`SecuredSpoSiteConfiguration`** - SharePoint site configuration for testing + +## Setup and Running + +### Step 1: Configure Microsoft Entra ID + +1. Register two applications in your Entra ID tenant: + - **Client Application** (this sample) + - **Server Application** (for the MCP server) + +2. Configure the client application: + - Set redirect URI: `http://localhost:1179/callback` + - Enable public client flows if needed + - Grant necessary API permissions + +3. Configure the server application: + - Expose an API with scopes (e.g., `user_impersonation`) + - Configure authentication settings + +### Step 2: Update Configuration + +1. Update `appsettings.json` with your tenant and application IDs +2. Set the client secret using user secrets (see above) +3. Optionally configure different SharePoint sites for testing + +### Step 3: Start the Protected MCP Server + +Start the EntraProtectedMcpServer which provides the protected tools: + +The protected server will start at `http://localhost:7071` + +### Step 4: Run the Protected MCP Client + +Run this client: + +```bash +dotnet run +``` + +```plaintext +Note: Ensure you have the necessary dependencies and runtimes installed. +``` + +## What Happens + +1. **Configuration Loading**: The client loads configuration from multiple sources (files, user secrets, environment variables) +2. **Configuration Validation**: Validates required settings and displays helpful error messages +3. **Server Connection**: Attempts to connect to the protected MCP server at the configured URL +4. **OAuth Discovery**: The server responds with OAuth metadata indicating authentication is required +5. **Authentication Flow**: The client initiates Microsoft Entra ID OAuth 2.0 authorization code flow: + - Opens a browser to the Entra ID authorization URL + - Starts a local HTTP listener on the configured redirect URI + - Exchanges the authorization code for an access token using PKCE +6. **API Access**: The client uses the access token to authenticate with the MCP server +7. **Tool Execution**: Lists available tools and demonstrates calling various protected tools: + - Weather alerts for Washington state + - Hello/greeting tools + - SharePoint site information retrieval + +## Available Tools + +Once authenticated, the client can access protected tools including: + +- **get_alerts**: Get weather alerts for a US state +- **get_forecast**: Get weather forecast for a location (latitude/longitude) +- **hello**: Simple greeting tool for testing using ("https://graph.microsoft.com/v1.0/me") +- **get_site_info**: Retrieve SharePoint site information (requires appropriate permissions) + +## Troubleshooting + +### Authentication Issues +- Ensure the ASP.NET Core dev certificate is trusted: +- Verify Entra ID application registrations and permissions +- Check that the client secret is correctly configured in user secrets +- Ensure the redirect URI matches exactly in both the code and Entra ID app registration + +### Configuration Issues +- Run with debug logging to see configuration loading details +- Verify all required configuration sections are present +- Check user secrets are properly configured: `dotnet user-secrets list --project samples/EntraProtectedMcpClient` + +### Connection Issues +- Ensure the EntraProtectedMcpServer is running and accessible +- Check that ports 7071 and 1179 are available +- Verify firewall settings allow the required connections + +### Browser Issues +- If the browser doesn't open automatically, copy the authorization URL from the console +- Allow/trust the OAuth server's certificate in your browser +- Clear browser cache/cookies if experiencing authentication issues + +## Security Considerations + +- **Client Secrets**: Never commit client secrets to source control. Always use user secrets for development and secure storage (Key Vault) for production +- **Redirect URIs**: Ensure redirect URIs are exactly configured in both the application and Entra ID +- **Scopes**: Request only the minimum necessary permissions/scopes +- **Token Storage**: The client handles token storage securely in memory only + +## Key Files + +- **`Program.cs`**: Main client application with OAuth flow implementation and configuration management +- **`EntraProtectedMcpClient.csproj`**: Project file with dependencies and user secrets configuration +- **`appsettings.json`**: Base application configuration (non-sensitive) +- **`Configuration/`**: Strongly-typed configuration classes +- `McpClientConfiguration.cs`: MCP server settings +- `EntraIdConfiguration.cs`: Entra ID authentication settings +- `SecuredSpoSiteConfiguration.cs`: SharePoint site configuration + +## Related Samples + +- **EntraProtectedMcpServer**: The corresponding server implementation +- **ProtectedMcpClient**: Alternative client using test OAuth server +- **ProtectedMcpServer**: Server using test OAuth server + +## Additional Resources + +- [Microsoft Entra ID Documentation](https://docs.microsoft.com/azure/active-directory/) +- [OAuth 2.0 Authorization Code Flow](https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-auth-code-flow) +- [.NET Configuration Documentation](https://docs.microsoft.com/aspnet/core/fundamentals/configuration/) +- [User Secrets in Development](https://docs.microsoft.com/aspnet/core/security/app-secrets) \ No newline at end of file diff --git a/samples/EntraProtectedMcpClient/appsettings.Development.json b/samples/EntraProtectedMcpClient/appsettings.Development.json new file mode 100644 index 000000000..5df6729a6 --- /dev/null +++ b/samples/EntraProtectedMcpClient/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Information" + } + } +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpClient/appsettings.json b/samples/EntraProtectedMcpClient/appsettings.json new file mode 100644 index 000000000..d6fe56eb3 --- /dev/null +++ b/samples/EntraProtectedMcpClient/appsettings.json @@ -0,0 +1,23 @@ +{ + "McpServer": { + "Url": "http://localhost:7071/" + }, + "EntraId": { + "TenantId": "your-tenant-id-here", + "ClientId": "your-client-id-here", + "ServerClientId": "your-server-client-id-here", // The ClientId of the MCP server application + "RedirectUri": "http://localhost:1179/callback", + "Scope": "user_impersonation", // The scope your MCP server exposes + "ResponseMode": "query" + }, + "SecuredSpoSite": { + "Url": "https://contoso.sharepoint.com/" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpServer/Configuration/EntraIdConfiguration.cs b/samples/EntraProtectedMcpServer/Configuration/EntraIdConfiguration.cs new file mode 100644 index 000000000..4278584f8 --- /dev/null +++ b/samples/EntraProtectedMcpServer/Configuration/EntraIdConfiguration.cs @@ -0,0 +1,15 @@ +namespace EntraProtectedMcpServer.Configuration; + +public sealed class EntraIdConfiguration +{ + public const string SectionName = "EntraId"; + + public required string TenantId { get; set; } + public required string ClientId { get; set; } + public required string ClientSecret { get; set; } + public string? AuthorityUrl { get; set; } + public string? TokenEndpoint { get; set; } + public List ValidAudiences { get; set; } = []; + public List ValidIssuers { get; set; } = []; + public List ScopesSupported { get; set; } = []; +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpServer/Configuration/ServerConfiguration.cs b/samples/EntraProtectedMcpServer/Configuration/ServerConfiguration.cs new file mode 100644 index 000000000..7822d5b46 --- /dev/null +++ b/samples/EntraProtectedMcpServer/Configuration/ServerConfiguration.cs @@ -0,0 +1,9 @@ +namespace EntraProtectedMcpServer.Configuration; + +public sealed class ServerConfiguration +{ + public const string SectionName = "Server"; + + public required string Url { get; set; } + public string? ResourceDocumentationUrl { get; set; } +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpServer/EntraProtectedMcpServer.csproj b/samples/EntraProtectedMcpServer/EntraProtectedMcpServer.csproj new file mode 100644 index 000000000..b4c35c779 --- /dev/null +++ b/samples/EntraProtectedMcpServer/EntraProtectedMcpServer.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + 783daef3-9c45-408d-a1d3-7caf44724f39 + + + + + + + + \ No newline at end of file diff --git a/samples/EntraProtectedMcpServer/Program.cs b/samples/EntraProtectedMcpServer/Program.cs new file mode 100644 index 000000000..be7852a42 --- /dev/null +++ b/samples/EntraProtectedMcpServer/Program.cs @@ -0,0 +1,187 @@ +using EntraProtectedMcpServer.Configuration; +using EntraProtectedMcpServer.Tools; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using ModelContextProtocol.AspNetCore.Authentication; +using System.Net.Http.Headers; +using System.Security.Claims; + +var builder = WebApplication.CreateBuilder(args); + +// Load configuration from multiple sources +builder.Configuration + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets(); + +// Bind configuration sections to strongly typed objects +var serverConfig = builder.Configuration.GetSection(ServerConfiguration.SectionName).Get(); +var entraConfig = builder.Configuration.GetSection(EntraIdConfiguration.SectionName).Get(); + +// Validate required configuration +ValidateConfiguration(serverConfig, entraConfig); + +// Inject IOptions +builder.Services.Configure( + builder.Configuration.GetSection(EntraIdConfiguration.SectionName)); + +// Build derived configuration values +var authorityUrl = entraConfig!.AuthorityUrl; +var validAudiences = entraConfig.ValidAudiences.Count > 0 ? entraConfig.ValidAudiences : [entraConfig.ClientId, $"api://{entraConfig.ClientId}"]; +var validIssuers = entraConfig.ValidIssuers.Count > 0 ? entraConfig.ValidIssuers : [authorityUrl!, $"https://sts.windows.net/{entraConfig.TenantId}/"]; +var scopesSupported = entraConfig.ScopesSupported.Select(scope => $"api://{entraConfig.ClientId}/{scope}").ToList(); + +// Configure authentication +ConfigureAuthentication(builder.Services, serverConfig!, entraConfig, authorityUrl!, validAudiences, validIssuers, scopesSupported); + +builder.Services.AddAuthorization(); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddMcpServer() + .WithToolsFromAssembly() + .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")); +}); + +// Configure HttpClientFactory for Microsoft Graph API +builder.Services.AddHttpClient("GraphApi", client => +{ + client.BaseAddress = new Uri("https://graph.microsoft.com"); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("graph-tool", "1.0")); +}); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapMcp().RequireAuthorization(); + +// Display startup information +DisplayStartupInfo(serverConfig!, entraConfig, authorityUrl!); + +app.Run(serverConfig!.Url); + +static void ValidateConfiguration(ServerConfiguration? serverConfig, EntraIdConfiguration? entraConfig) +{ + if (serverConfig?.Url is null) + { + throw new InvalidOperationException("Server:Url configuration is required."); + } + + if (entraConfig is null) + { + throw new InvalidOperationException("EntraId configuration section is required."); + } + + if (string.IsNullOrEmpty(entraConfig.TenantId)) + { + throw new InvalidOperationException("EntraId:TenantId configuration is required."); + } + + if (string.IsNullOrEmpty(entraConfig.ClientId)) + { + throw new InvalidOperationException("EntraId:ClientId configuration is required."); + } + + if (string.IsNullOrEmpty(entraConfig.ClientSecret)) + { + throw new InvalidOperationException("EntraId:ClientSecret configuration is required. Consider using user secrets or environment variables for production."); + } + + if (string.IsNullOrEmpty(entraConfig.AuthorityUrl)) + { + throw new InvalidOperationException("EntraId:AuthorityUrl configuration is required."); + } + + if (string.IsNullOrEmpty(entraConfig.TokenEndpoint)) + { + throw new InvalidOperationException("EntraId:TokenEndpoint configuration is required."); + } +} + +static void ConfigureAuthentication( + IServiceCollection services, + ServerConfiguration serverConfig, + EntraIdConfiguration entraConfig, + string authorityUrl, + IList validAudiences, + IList validIssuers, + IList scopesSupported) +{ + services.AddAuthentication(options => + { + options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.Authority = authorityUrl; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidAudiences = validAudiences, + ValidIssuers = validIssuers, + NameClaimType = ClaimTypes.Name, + RoleClaimType = ClaimTypes.Role + }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + var name = context.Principal?.FindFirstValue("name") ?? + context.Principal?.FindFirstValue("preferred_username") ?? "unknown"; + var upn = context.Principal?.FindFirstValue("upn") ?? + context.Principal?.FindFirstValue("email") ?? + context.Principal?.FindFirstValue("preferred_username") ?? "unknown"; + var tenantId = context.Principal?.FindFirstValue("tid"); + + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogInformation("Token validated for: {Name} ({Upn}) from tenant: {TenantId}", name, upn, tenantId); + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogWarning("Authentication failed: {Message}", context.Exception.Message); + return Task.CompletedTask; + }, + OnChallenge = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogInformation("Challenging client to authenticate with Entra ID"); + return Task.CompletedTask; + } + }; + }) + .AddMcp(options => + { + options.ResourceMetadata = new() + { + Resource = new Uri(serverConfig.Url), + ResourceDocumentation = serverConfig.ResourceDocumentationUrl != null ? new Uri(serverConfig.ResourceDocumentationUrl) : null, + AuthorizationServers = { new Uri(entraConfig.AuthorityUrl!) }, + ScopesSupported = scopesSupported.ToList(), + }; + }); +} + +static void DisplayStartupInfo(ServerConfiguration serverConfig, EntraIdConfiguration entraConfig, string authorityUrl) +{ + Console.WriteLine($"Starting MCP server with Entra ID authorization at {serverConfig.Url}"); + Console.WriteLine($"Using Microsoft Entra ID tenant: {entraConfig.TenantId}"); + Console.WriteLine($"Client ID: {entraConfig.ClientId}"); + Console.WriteLine($"Authority: {authorityUrl}"); + Console.WriteLine($"Protected Resource Metadata URL: {serverConfig.Url}.well-known/oauth-protected-resource"); + Console.WriteLine("Press Ctrl+C to stop the server"); +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpServer/Properties/launchSettings.json b/samples/EntraProtectedMcpServer/Properties/launchSettings.json new file mode 100644 index 000000000..dbc9a1147 --- /dev/null +++ b/samples/EntraProtectedMcpServer/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ProtectedMcpServer": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:7071" + } + } +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpServer/README.md b/samples/EntraProtectedMcpServer/README.md new file mode 100644 index 000000000..c21be35e3 --- /dev/null +++ b/samples/EntraProtectedMcpServer/README.md @@ -0,0 +1,295 @@ +# Protected MCP Server Sample with Microsoft Entra ID + +This sample demonstrates how to create an MCP server that requires Microsoft Entra ID (Azure AD) OAuth 2.0 +authentication to access its tools and resources. The server provides weather-related tools, Microsoft Graph +integration, and SharePoint tools protected by JWT bearer token authentication. + +## Overview + +The EntraProtectedMcpServer sample shows how to: +- Create an MCP server with Microsoft Entra ID OAuth 2.0 protection +- Configure JWT bearer token authentication with Azure AD +- Implement protected MCP tools and resources +- Integrate with ASP.NET Core authentication and authorization +- Provide OAuth resource metadata for client discovery +- Use On-Behalf-Of (OBO) flow for Microsoft Graph and SharePoint API access + +## Features + +- **Microsoft Entra ID Integration**: Full OAuth 2.0 authentication with Microsoft identity platform +- **Weather Tools**: National Weather Service API integration for alerts and forecasts +- **Microsoft Graph Tools**: Personalized user information and Graph API access +- **SharePoint Tools**: SharePoint Online REST API integration for site information +- **On-Behalf-Of Flow**: Secure token exchange for downstream API calls +- **User Secrets**: Secure storage of sensitive information during development + +## Prerequisites + +- .NET 9.0 or later +- Microsoft Entra ID tenant with registered applications +- Valid Entra ID credentials and API permissions configured +- Access to Microsoft Graph and SharePoint Online (if using those tools) + +## Configuration + +### Configuration Files + +The server uses a layered configuration approach: + +1. **appsettings.json** - Base configuration (non-sensitive) +2. **appsettings.Development.json** - Development overrides +3. **User Secrets** - Sensitive data (client secrets, credentials) +4. **Environment Variables** - Runtime overrides + +### Configuration Structure + +```json +{ + "Server": { + "Url": "http://localhost:7071/", + "ResourceDocumentationUrl": "https://docs.example.com/api/weather" + }, + "EntraId": { + "TenantId": "your-tenant-id", + "ClientId": "your-client-id", + "ClientSecret": "client-secret-from-user-secrets"," + "ScopesSupported": [ + "user_impersonation" + ] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} +``` + + +### Configuration Classes + +The server uses strongly-typed configuration classes: + +- **`ServerConfiguration`** - Server hosting and endpoint settings +- **`EntraIdConfiguration`** - Microsoft Entra ID authentication settings + +## Setup and Running + +### Step 1: Configure Microsoft Entra ID + +1. Register an application in your Entra ID tenant for the MCP server +2. Configure API permissions: + - **Microsoft Graph**: `User.Read` (for Graph tools) + - **SharePoint**: `Sites.Read.All` or appropriate site permissions (for SharePoint tools) +3. Create a client secret for the On-Behalf-Of flow +4. Note down the Tenant ID and Client ID + +### Step 2: Update Configuration + +1. Update `appsettings.json` with your tenant and application IDs +2. Set the client secret using user secrets (see above) +3. Optionally configure external API settings + +### Step 3: Run the Protected MCP Server + +## Running the Server + +### Locally with Visual Studio + +1. Press F5 to build and run the server +2. The server will start at `http://localhost:7071` + +### Docker + +To run the server in a Docker container: + +```bash +docker build -t entra-protected-mcp-server . +docker run -d -p 7071:80 --name mcp-server entra-protected-mcp-server +``` + +## Testing the Server + +### With Protected MCP Client + +Use the ProtectedMcpClient sample to test the server: + +```bash +cd samples\ProtectedMcpClient +dotnet run +``` + + +## Testing Without Client + +You can test the server directly using HTTP tools: + +1. Get an access token from Microsoft Entra ID with appropriate scopes +2. Include the token in the `Authorization: Bearer ` header +3. Make requests to the MCP endpoints: + - `POST http://localhost:7071/` with MCP JSON-RPC requests + - `GET http://localhost:7071/.well-known/oauth-protected-resource` for OAuth metadata + +### Example MCP Request + +``` +curl -X POST http://localhost:7071/ +-H "Authorization: Bearer YOUR_ACCESS_TOKEN" +-H "Content-Type: application/json" +-H "ProtocolVersion: 2025-06-18" +-d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "hello" } }' +``` + +## What the Server Provides + +### Protected Resources + +- **MCP Endpoint**: `http://localhost:7071/` (requires authentication) +- **OAuth Resource Metadata**: `http://localhost:7071/.well-known/oauth-protected-resource` + +### Available Tools + +The server provides several categories of protected tools: + +#### Weather Tools (WeatherTools.cs) +1. **GetAlerts**: Get weather alerts for a US state + - Parameter: `state` (string) - 2-letter US state abbreviation + - Example: `GetAlerts` with `state: "WA"` + - Data source: National Weather Service API + +2. **GetForecast**: Get weather forecast for a location + - Parameters: + - `latitude` (double) - Latitude coordinate + - `longitude` (double) - Longitude coordinate + - Example: `GetForecast` with `latitude: 47.6062, longitude: -122.3321` + - Data source: National Weather Service API + +#### Microsoft Graph Tools (GraphTools.cs) +1. **Hello**: Get a personalized greeting using Microsoft Graph + - No parameters required + - Uses On-Behalf-Of flow to access Microsoft Graph + - Retrieves user's display name from `/me` endpoint + - Requires: `User.Read` permission + +#### SharePoint Tools (SPOTools.cs) +1. **GetSiteInfo**: Get SharePoint site information + - Parameter: `siteUrl` (string) - SharePoint site URL + - Example: `GetSiteInfo` with `siteUrl: "https://contoso.sharepoint.com/sites/sitename"` + - Uses On-Behalf-Of flow to access SharePoint REST API + - Returns site title, description, URL, creation date, and language + - Requires: Appropriate SharePoint permissions + +### Authentication & Authorization Flow + +1. **Initial Authentication**: Client authenticates with Entra ID and receives an access token +2. **MCP Request**: Client includes the access token in the `Authorization: Bearer` header +3. **Token Validation**: Server validates the JWT token against Entra ID +4. **On-Behalf-Of Flow**: For Graph/SharePoint tools, server exchanges the user's token for downstream API tokens +5. **API Access**: Server uses the exchanged tokens to call Microsoft Graph or SharePoint APIs +6. **Response**: Server returns the processed results to the client + +## Architecture + +The server uses: +- **ASP.NET Core** for hosting and HTTP handling +- **JWT Bearer Authentication** for Microsoft Entra ID token validation +- **MCP Authentication Extensions** for OAuth resource metadata +- **On-Behalf-Of (OBO) Flow** for secure token exchange +- **HttpClientFactory** for calling external APIs (Weather.gov, Microsoft Graph, SharePoint) +- **Authorization** to protect MCP endpoints +- **Strongly-typed Configuration** for settings management + +## Configuration Details + +### Default Settings +- **Server URL**: `http://localhost:7071` +- **Resource Documentation**: `https://docs.example.com/api/weather` +- **Weather API**: `https://api.weather.gov` +- **Microsoft Graph API**: `https://graph.microsoft.com` + +### Required Permissions +- **Microsoft Graph**: `User.Read` +- **SharePoint**: `Sites.Read.All` or site-specific permissions +- **MCP Server**: `user_impersonation` scope + + +## External Dependencies + +- **National Weather Service API** (`api.weather.gov`): Real weather data +- **Microsoft Graph API** (`graph.microsoft.com`): User information and Microsoft 365 data +- **SharePoint REST API**: SharePoint Online site and content access +- **Microsoft Entra ID**: Authentication and token services + +## Troubleshooting + +### Configuration Issues +- Verify all required configuration sections are present in `appsettings.json` +- Check user secrets are properly configured: `dotnet user-secrets list --project samples/EntraProtectedMcpServer` +- Ensure client secret is set for On-Behalf-Of flow + +### Authentication Issues +- Ensure the ASP.NET Core dev certificate is trusted: + +``` +dotnet dev-certs https --clean dotnet dev-certs https --trust +``` + +- Verify Entra ID application registrations and permissions +- Check token audience and issuer validation settings +- Ensure API permissions are granted and admin consented + +### On-Behalf-Of Flow Issues +- Verify the client application has the necessary delegated permissions +- Check that the client secret is correctly configured +- Ensure the original access token has the required scopes +- Verify the target APIs (Graph/SharePoint) are accessible + +### Network Issues +- Check that port 7071 is available and not blocked by firewall +- Verify external API endpoints are accessible (weather.gov, graph.microsoft.com) +- Check proxy settings if behind a corporate firewall + +### API Permission Issues +- Ensure Microsoft Graph permissions are granted: `User.Read` +- Verify SharePoint permissions are appropriate for the target sites +- Check that admin consent has been provided for application permissions + +## Security Considerations + +- **Client Secrets**: Never commit client secrets to source control. Always use user secrets for development and secure storage (Key Vault) for production +- **Token Scoping**: Request only the minimum necessary permissions/scopes +- **On-Behalf-Of Flow**: Tokens are exchanged securely and not stored persistently +- **HTTPS**: Use HTTPS in production environments +- **Token Validation**: All tokens are properly validated against Microsoft Entra ID + +## Key Files + +- **`Program.cs`**: Server setup with authentication and MCP configuration +- **`Configuration/`**: Strongly-typed configuration classes +- `ServerConfiguration.cs`: Server hosting settings +- `EntraIdConfiguration.cs`: Entra ID authentication settings +- `ExternalApiConfiguration.cs`: External API configurations +- **`Tools/`**: MCP tool implementations +- `WeatherTools.cs`: Weather-related tools using weather.gov API +- `GraphTools.cs`: Microsoft Graph integration with On-Behalf-Of flow +- `SPOTools.cs`: SharePoint Online REST API integration +- **`appsettings.json`**: Base application configuration +- **`EntraProtectedMcpServer.csproj`**: Project file with dependencies and user secrets + +## Related Samples + +- **EntraProtectedMcpClient**: Corresponding client implementation with Entra ID +- **ProtectedMcpClient**: Alternative client using test OAuth server +- **ProtectedMcpServer**: Server using test OAuth server instead of Entra ID + +## Additional Resources + +- [Microsoft Entra ID Documentation](https://docs.microsoft.com/azure/active-directory/) +- [On-Behalf-Of Flow](https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow) +- [Microsoft Graph API](https://docs.microsoft.com/graph/) +- [SharePoint REST API](https://docs.microsoft.com/sharepoint/dev/sp-add-ins/get-to-know-the-sharepoint-rest-service) +- [ASP.NET Core Authentication](https://docs.microsoft.com/aspnet/core/security/authentication/) +- [.NET Configuration](https://docs.microsoft.com/aspnet/core/fundamentals/configuration/) \ No newline at end of file diff --git a/samples/EntraProtectedMcpServer/Tools/GraphTools.cs b/samples/EntraProtectedMcpServer/Tools/GraphTools.cs new file mode 100644 index 000000000..8265880d2 --- /dev/null +++ b/samples/EntraProtectedMcpServer/Tools/GraphTools.cs @@ -0,0 +1,162 @@ +using ModelContextProtocol; +using ModelContextProtocol.Server; +using EntraProtectedMcpServer.Configuration; +using System.ComponentModel; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace EntraProtectedMcpServer.Tools; + +[McpServerToolType] +public sealed class GraphTools +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IConfiguration _configuration; + private readonly EntraIdConfiguration _entraIdConfig; + + /// + /// Initializes a new instance of the GraphTools class. + /// + public GraphTools( + IHttpClientFactory httpClientFactory, + IHttpContextAccessor httpContextAccessor, + IConfiguration configuration, + IOptions entraIdOptions) + { + _httpClientFactory = httpClientFactory; + _httpContextAccessor = httpContextAccessor; + _configuration = configuration; + _entraIdConfig = entraIdOptions.Value; + } + + /// + /// Gets a personalized greeting using Microsoft Graph to retrieve the user's display name. + /// + /// A personalized hello message with the user's name. + [McpServerTool, Description("Get a personalized hello message using Microsoft Graph.")] + public async Task Hello() + { + // Get the current user's access token from the HTTP context + var httpContext = _httpContextAccessor.HttpContext + ?? throw new McpException("HTTP context not available"); + + var authHeader = httpContext.Request.Headers.Authorization.FirstOrDefault(); + if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ")) + { + throw new McpException("No valid bearer token found in request"); + } + + var mcpAccessToken = authHeader["Bearer ".Length..]; + + try + { + // Exchange the MCP token for a Microsoft Graph token using On-Behalf-Of flow + var graphAccessToken = await GetGraphTokenAsync(mcpAccessToken); + + // Create HTTP client for Microsoft Graph + var client = _httpClientFactory.CreateClient("GraphApi"); + + // Add the Graph access token to the request + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphAccessToken); + + // Call Microsoft Graph /me endpoint + var response = await client.GetAsync("/v1.0/me"); + + if (!response.IsSuccessStatusCode) + { + throw new McpException($"Microsoft Graph API call failed: {response.StatusCode} - {await response.Content.ReadAsStringAsync()}"); + } + + var jsonContent = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(jsonContent); + var root = document.RootElement; + + // Extract the user's display name from the response + var displayName = root.TryGetProperty("displayName", out var nameElement) + ? nameElement.GetString() + : null; + + // Fallback to other name properties if displayName is not available + if (string.IsNullOrEmpty(displayName)) + { + displayName = root.TryGetProperty("givenName", out var givenNameElement) + ? givenNameElement.GetString() + : root.TryGetProperty("userPrincipalName", out var upnElement) + ? upnElement.GetString()?.Split('@')[0] // Take the part before @ for UPN + : "User"; + } + + return $"Hello, {displayName}!!"; + } + catch (HttpRequestException ex) + { + throw new McpException($"Failed to call Microsoft Graph API: {ex.Message}", ex); + } + catch (JsonException ex) + { + throw new McpException($"Failed to parse Microsoft Graph API response: {ex.Message}", ex); + } + } + + /// + /// Exchanges an MCP access token for a Microsoft Graph access token using On-Behalf-Of flow. + /// + /// The original MCP access token. + /// A Microsoft Graph access token. + private async Task GetGraphTokenAsync(string mcpAccessToken) + { + // Validate required configuration + if (string.IsNullOrEmpty(_entraIdConfig.TenantId)) + { + throw new McpException("EntraId:TenantId configuration is required for On-Behalf-Of flow"); + } + + if (string.IsNullOrEmpty(_entraIdConfig.ClientId)) + { + throw new McpException("EntraId:ClientId configuration is required for On-Behalf-Of flow"); + } + + // Get client secret from configuration (should be in user secrets or secure storage) + var clientSecret = _entraIdConfig.ClientSecret ?? _configuration["AzureAd:ClientSecret"]; + if (string.IsNullOrEmpty(clientSecret)) + { + throw new McpException("Client secret not configured for On-Behalf-Of flow. Check EntraId:ClientSecret or AzureAd:ClientSecret configuration."); + } + + var client = _httpClientFactory.CreateClient(); + + var tokenEndpoint = _entraIdConfig.TokenEndpoint; + + var requestParams = new Dictionary + { + ["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer", + ["client_id"] = _entraIdConfig.ClientId, + ["client_secret"] = clientSecret, + ["assertion"] = mcpAccessToken, + ["scope"] = "https://graph.microsoft.com/User.Read", + ["requested_token_use"] = "on_behalf_of" + }; + + var requestContent = new FormUrlEncodedContent(requestParams); + + var response = await client.PostAsync(tokenEndpoint, requestContent); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new McpException($"On-Behalf-Of token exchange failed: {response.StatusCode} - {errorContent}"); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(responseContent); + + if (!document.RootElement.TryGetProperty("access_token", out var tokenElement)) + { + throw new McpException("No access token in On-Behalf-Of response"); + } + + return tokenElement.GetString() ?? throw new McpException("Access token is null"); + } +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpServer/Tools/SPOTools.cs b/samples/EntraProtectedMcpServer/Tools/SPOTools.cs new file mode 100644 index 000000000..a46634e37 --- /dev/null +++ b/samples/EntraProtectedMcpServer/Tools/SPOTools.cs @@ -0,0 +1,149 @@ +using ModelContextProtocol; +using ModelContextProtocol.Server; +using EntraProtectedMcpServer.Configuration; +using System.ComponentModel; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace EntraProtectedMcpServer.Tools; + +[McpServerToolType] +public sealed class SPOTools +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IConfiguration _configuration; + private readonly EntraIdConfiguration _entraIdConfig; + + /// + /// Initializes a new instance of the SPOTools class. + /// + public SPOTools( + IHttpClientFactory httpClientFactory, + IHttpContextAccessor httpContextAccessor, + IConfiguration configuration, + IOptions entraIdOptions) + { + _httpClientFactory = httpClientFactory; + _httpContextAccessor = httpContextAccessor; + _configuration = configuration; + _entraIdConfig = entraIdOptions.Value; + } + + /// + /// Retrieves SharePoint site information including title, description, URL, creation date, and language. + /// + /// The SharePoint site URL. + /// Formatted site information as a string. + [McpServerTool, Description("Get SharePoint site information using SharePoint REST API.")] + public async Task GetSiteInfo( + [Description("The SharePoint site URL (e.g., https://contoso.sharepoint.com/sites/sitename)")] string siteUrl) + { + var spoAccessToken = await GetSharePointTokenAsync(siteUrl); + var client = _httpClientFactory.CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", spoAccessToken); + client.DefaultRequestHeaders.Add("Accept", "application/json;odata=verbose"); + + var apiUrl = $"{siteUrl.TrimEnd('/')}/_api/web"; + var response = await client.GetAsync(apiUrl); + + if (!response.IsSuccessStatusCode) + { + throw new McpException($"SharePoint REST API call failed: {response.StatusCode} - {await response.Content.ReadAsStringAsync()}"); + } + + var jsonContent = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(jsonContent); + var webInfo = document.RootElement.GetProperty("d"); + + var title = webInfo.TryGetProperty("Title", out var titleElement) ? titleElement.GetString() : "Unknown"; + var description = webInfo.TryGetProperty("Description", out var descElement) ? descElement.GetString() : ""; + var url = webInfo.TryGetProperty("Url", out var urlElement) ? urlElement.GetString() : ""; + var created = webInfo.TryGetProperty("Created", out var createdElement) ? createdElement.GetString() : ""; + var language = webInfo.TryGetProperty("Language", out var langElement) ? langElement.GetInt32() : 0; + + return $""" + Site Title: {title} + Description: {description} + URL: {url} + Created: {created} + Language: {language} + """; + } + + /// + /// Exchanges an MCP access token for a SharePoint access token using On-Behalf-Of flow. + /// + /// The SharePoint site URL to determine the resource scope. + /// A SharePoint access token. + private async Task GetSharePointTokenAsync(string siteUrl) + { + var httpContext = _httpContextAccessor.HttpContext + ?? throw new McpException("HTTP context not available"); + + var authHeader = httpContext.Request.Headers.Authorization.FirstOrDefault(); + if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ")) + { + throw new McpException("No valid bearer token found in request"); + } + + var mcpAccessToken = authHeader["Bearer ".Length..]; + + // Validate required configuration + if (string.IsNullOrEmpty(_entraIdConfig.TenantId)) + { + throw new McpException("EntraId:TenantId configuration is required for On-Behalf-Of flow"); + } + + if (string.IsNullOrEmpty(_entraIdConfig.ClientId)) + { + throw new McpException("EntraId:ClientId configuration is required for On-Behalf-Of flow"); + } + + // Get client secret from configuration (should be in user secrets or secure storage) + var clientSecret = _entraIdConfig.ClientSecret ?? _configuration["AzureAd:ClientSecret"]; + if (string.IsNullOrEmpty(clientSecret)) + { + throw new McpException("Client secret not configured for On-Behalf-Of flow. Check EntraId:ClientSecret or AzureAd:ClientSecret configuration."); + } + + // Extract SharePoint resource from URL + var uri = new Uri(siteUrl); + var spoResource = $"https://{uri.Host}"; + + var client = _httpClientFactory.CreateClient(); + + var tokenEndpoint = _entraIdConfig.TokenEndpoint; + + var requestParams = new Dictionary + { + ["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer", + ["client_id"] = _entraIdConfig.ClientId, + ["client_secret"] = clientSecret, + ["assertion"] = mcpAccessToken, + ["scope"] = $"{spoResource}/.default", + ["requested_token_use"] = "on_behalf_of" + }; + + var requestContent = new FormUrlEncodedContent(requestParams); + var response = await client.PostAsync(tokenEndpoint, requestContent); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new McpException($"SharePoint On-Behalf-Of token exchange failed: {response.StatusCode} - {errorContent}"); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(responseContent); + + if (!document.RootElement.TryGetProperty("access_token", out var tokenElement)) + { + throw new McpException("No access token in SharePoint On-Behalf-Of response"); + } + + return tokenElement.GetString() ?? throw new McpException("SharePoint access token is null"); + } +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpServer/Tools/WeatherTools.cs b/samples/EntraProtectedMcpServer/Tools/WeatherTools.cs new file mode 100644 index 000000000..9b7fe28dd --- /dev/null +++ b/samples/EntraProtectedMcpServer/Tools/WeatherTools.cs @@ -0,0 +1,70 @@ +using ModelContextProtocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; + +namespace EntraProtectedMcpServer.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.GetFromJsonAsync($"/alerts/active/area/{state}") + ?? throw new McpException("No JSON returned from alerts endpoint"); + + var alerts = jsonDocument.RootElement.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 locationDocument = await client.GetFromJsonAsync(pointUrl); + var forecastUrl = locationDocument?.RootElement.GetProperty("properties").GetProperty("forecast").GetString() + ?? throw new McpException($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}"); + + using var forecastDocument = await client.GetFromJsonAsync(forecastUrl); + var periods = forecastDocument?.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray() + ?? throw new McpException("No JSON returned from forecast endpoint"); + + 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/samples/EntraProtectedMcpServer/appsettings.Development.json b/samples/EntraProtectedMcpServer/appsettings.Development.json new file mode 100644 index 000000000..f999bc20e --- /dev/null +++ b/samples/EntraProtectedMcpServer/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} \ No newline at end of file diff --git a/samples/EntraProtectedMcpServer/appsettings.json b/samples/EntraProtectedMcpServer/appsettings.json new file mode 100644 index 000000000..9c0e5642f --- /dev/null +++ b/samples/EntraProtectedMcpServer/appsettings.json @@ -0,0 +1,23 @@ +{ + "Server": { + "Url": "http://localhost:7071/", + "ResourceDocumentationUrl": "https://docs.example.com/api/weather" + }, + "EntraId": { + "TenantId": "your-tenant-id-here", + "ClientId": "your-client-id-here", + "AuthorityUrl": "https://login.microsoftonline.com/your-tenant-id-here/v2.0", + "TokenEndpoint": "https://login.microsoftonline.com/your-tenant-id-here/oauth2/v2.0/token", + "ScopesSupported": [ + "user_impersonation" // The scope your MCP server exposes + ] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 468728982..c1646acb8 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -300,14 +300,26 @@ private async Task GetAuthServerMetadataAsync(Uri a private async Task RefreshTokenAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) { - var requestContent = new FormUrlEncodedContent(new Dictionary + var requestParams = new Dictionary { ["grant_type"] = "refresh_token", ["refresh_token"] = refreshToken, ["client_id"] = GetClientIdOrThrow(), ["client_secret"] = _clientSecret ?? string.Empty, - ["resource"] = resourceUri.ToString(), - }); + }; + + // Only add resource parameter for non-Entra servers + if (authServerMetadata.Issuer?.Host != "login.microsoftonline.com") + { + requestParams["resource"] = resourceUri.ToString(); + } + // For Microsoft Entra ID, use scope instead of resource + else if (_scopes is not null && _scopes.Length > 0) + { + requestParams["scope"] = string.Join(" ", _scopes); + } + + var requestContent = new FormUrlEncodedContent(requestParams); using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint) { @@ -384,7 +396,7 @@ private async Task ExchangeCodeForTokenAsync( string codeVerifier, CancellationToken cancellationToken) { - var requestContent = new FormUrlEncodedContent(new Dictionary + var requestParams = new Dictionary { ["grant_type"] = "authorization_code", ["code"] = authorizationCode, @@ -392,8 +404,20 @@ private async Task ExchangeCodeForTokenAsync( ["client_id"] = GetClientIdOrThrow(), ["code_verifier"] = codeVerifier, ["client_secret"] = _clientSecret ?? string.Empty, - ["resource"] = protectedResourceMetadata.Resource.ToString(), - }); + }; + + // Only add resource parameter for non-Entra servers + if (authServerMetadata.Issuer?.Host != "login.microsoftonline.com") + { + requestParams["resource"] = protectedResourceMetadata.Resource.ToString(); + } + // For Microsoft Entra ID, use scope instead of resource + else if (_scopes is not null && _scopes.Length > 0) + { + requestParams["scope"] = string.Join(" ", _scopes); + } + + var requestContent = new FormUrlEncodedContent(requestParams); using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint) {