diff --git a/Directory.Packages.props b/Directory.Packages.props index 34bdac87..d27e9ce5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -42,8 +42,9 @@ - - + + + @@ -52,5 +53,6 @@ + diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index 3048e753..a8e706cf 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -46,7 +46,7 @@ public AIChatService(IOptions options, AISearchService searchService, string prompt, string? systemPrompt = null, string? previousResponseId = null, - IMcpClient? mcpClient = null, + McpClient? mcpClient = null, #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. IEnumerable? tools = null, ResponseReasoningEffortLevel? reasoningEffortLevel = null, @@ -56,7 +56,7 @@ public AIChatService(IOptions options, AISearchService searchService, { var responseOptions = await CreateResponseOptionsAsync(previousResponseId, tools, reasoningEffortLevel, mcpClient: mcpClient, cancellationToken: cancellationToken); var enrichedPrompt = await EnrichPromptWithContext(prompt, enableContextualSearch, cancellationToken); - return await GetChatCompletionCore(enrichedPrompt, responseOptions, systemPrompt, cancellationToken); + return await GetChatCompletionCore(enrichedPrompt, responseOptions, systemPrompt, mcpClient, cancellationToken); } /// @@ -74,7 +74,7 @@ public AIChatService(IOptions options, AISearchService searchService, string prompt, string? systemPrompt = null, string? previousResponseId = null, - IMcpClient? mcpClient = null, + McpClient? mcpClient = null, #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. IEnumerable? tools = null, ResponseReasoningEffortLevel? reasoningEffortLevel = null, @@ -102,7 +102,7 @@ public AIChatService(IOptions options, AISearchService searchService, options: responseOptions, cancellationToken: cancellationToken); - await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, cancellationToken)) + await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, toolCallDepth: 0, cancellationToken)) { yield return result; } @@ -146,7 +146,8 @@ private async Task EnrichPromptWithContext(string prompt, bool enableCon IAsyncEnumerable streamingUpdates, ResponseCreationOptions responseOptions, #pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - IMcpClient? mcpClient, + McpClient? mcpClient, + int toolCallDepth = 0, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { await foreach (var update in streamingUpdates.WithCancellation(cancellationToken)) @@ -163,8 +164,11 @@ private async Task EnrichPromptWithContext(string prompt, bool enableCon // Check if this is a function call that needs to be executed if (itemDone.Item is FunctionCallResponseItem functionCallItem && mcpClient != null) { + if (toolCallDepth >= 10) + throw new InvalidOperationException("Maximum tool call depth exceeded."); + // Execute the function call and stream its response - await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, cancellationToken)) + await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, toolCallDepth + 1, cancellationToken)) { if (functionResult.responseId != null) { @@ -194,7 +198,8 @@ private async Task EnrichPromptWithContext(string prompt, bool enableCon FunctionCallResponseItem functionCallItem, ResponseCreationOptions responseOptions, #pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - IMcpClient mcpClient, + McpClient mcpClient, + int toolCallDepth, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { // A dictionary of arguments to pass to the tool. Each key represents a parameter name, and its associated value represents the argument value. @@ -247,7 +252,7 @@ private async Task EnrichPromptWithContext(string prompt, bool enableCon responseOptions, cancellationToken); - await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, cancellationToken)) + await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, toolCallDepth, cancellationToken)) { yield return result; } @@ -261,7 +266,7 @@ private static async Task CreateResponseOptionsAsync( string? previousResponseId = null, IEnumerable? tools = null, ResponseReasoningEffortLevel? reasoningEffortLevel = null, - IMcpClient? mcpClient = null, + McpClient? mcpClient = null, CancellationToken cancellationToken = default ) { @@ -285,7 +290,8 @@ private static async Task CreateResponseOptionsAsync( if (mcpClient is not null) { - await foreach (McpClientTool tool in mcpClient.EnumerateToolsAsync(cancellationToken: cancellationToken)) + var mcpTools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken); + foreach (McpClientTool tool in mcpTools) { #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. options.Tools.Add(ResponseTool.CreateFunctionTool(tool.Name, functionDescription: tool.Description, strictModeEnabled: true, functionParameters: BinaryData.FromString(tool.JsonSchema.GetRawText()))); @@ -316,44 +322,84 @@ private static async Task CreateResponseOptionsAsync( ResponseCreationOptions responseOptions, #pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. string? systemPrompt = null, + McpClient? mcpClient = null, CancellationToken cancellationToken = default) { - // Construct the user input with system context if provided var systemContext = systemPrompt ?? _Options.SystemPrompt; - // Create the streaming response using the Responses API #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. List responseItems = [ResponseItem.CreateUserMessageItem(prompt)]; if (systemContext is not null) { - responseItems.Add( - ResponseItem.CreateSystemMessageItem(systemContext)); + responseItems.Add(ResponseItem.CreateSystemMessageItem(systemContext)); } #pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - // Create the response using the Responses API - var response = await _ResponseClient.CreateResponseAsync( - responseItems, - options: responseOptions, - cancellationToken: cancellationToken); + const int MaxToolCallIterations = 10; + for (int iteration = 0; iteration < MaxToolCallIterations; iteration++) + { + var response = await _ResponseClient.CreateResponseAsync( + responseItems, + options: responseOptions, + cancellationToken: cancellationToken); - // Extract the message content and response ID - string responseText = string.Empty; - string responseId = response.Value.Id; + string responseId = response.Value.Id; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var assistantMessage = response.Value.OutputItems - .OfType() - .FirstOrDefault(m => m.Role == MessageRole.Assistant && - !string.IsNullOrEmpty(m.Content?.FirstOrDefault()?.Text)); + var functionCalls = response.Value.OutputItems.OfType().ToList(); - if (assistantMessage is not null) - { - responseText = assistantMessage.Content?.FirstOrDefault()?.Text ?? string.Empty; - } + if (functionCalls.Count > 0 && mcpClient != null) + { + foreach (var functionCallItem in functionCalls) + { + var jsonResponse = functionCallItem.FunctionArguments.ToString(); + var jsonArguments = System.Text.Json.JsonSerializer.Deserialize>(jsonResponse) ?? new Dictionary(); + + Dictionary arguments = []; + foreach (var kvp in jsonArguments) + { + if (kvp.Value is System.Text.Json.JsonElement jsonElement) + { + arguments[kvp.Key] = jsonElement.ValueKind switch + { + System.Text.Json.JsonValueKind.String => jsonElement.GetString(), + System.Text.Json.JsonValueKind.Number => jsonElement.GetDecimal(), + System.Text.Json.JsonValueKind.True => true, + System.Text.Json.JsonValueKind.False => false, + System.Text.Json.JsonValueKind.Null => null, + _ => jsonElement.ToString() + }; + } + else + { + arguments[kvp.Key] = kvp.Value; + } + } + + var toolResult = await mcpClient.CallToolAsync( + functionCallItem.FunctionName, + arguments: arguments, + cancellationToken: cancellationToken); + + responseItems.Add(functionCallItem); + responseItems.Add(new FunctionCallOutputResponseItem( + functionCallItem.CallId, + string.Join("", toolResult.Content.Where(x => x.Type == "text").OfType().Select(x => x.Text)))); + } + continue; + } + + var assistantMessage = response.Value.OutputItems + .OfType() + .FirstOrDefault(m => m.Role == MessageRole.Assistant && + !string.IsNullOrEmpty(m.Content?.FirstOrDefault()?.Text)); + + string responseText = assistantMessage?.Content?.FirstOrDefault()?.Text ?? string.Empty; #pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + return (responseText, responseId); + } - return (responseText, responseId); + throw new InvalidOperationException("Maximum tool call iterations exceeded."); } // TODO: Look into using UserSecurityContext (https://learn.microsoft.com/en-us/azure/defender-for-cloud/gain-end-user-context-ai) diff --git a/EssentialCSharp.Web.Tests/McpTests.cs b/EssentialCSharp.Web.Tests/McpTests.cs new file mode 100644 index 00000000..b246f3d0 --- /dev/null +++ b/EssentialCSharp.Web.Tests/McpTests.cs @@ -0,0 +1,119 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using System.Threading.Tasks; + +namespace EssentialCSharp.Web.Tests; + +public class McpTests +{ + [Fact] + public async Task McpTokenEndpoint_WithoutAuth_Returns401() + { + using WebApplicationFactory factory = new(); + HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + using HttpResponseMessage response = await client.PostAsync("/api/McpToken", null); + + // [ApiController] returns 401 directly; it does not redirect to login like Razor Pages + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task McpEndpoint_WithoutToken_Returns401() + { + using WebApplicationFactory factory = new(); + HttpClient client = factory.CreateClient(); + + var request = CreateMcpInitializeRequest("/mcp"); + using HttpResponseMessage response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task McpEndpoint_WithValidToken_Returns200AndListsTools() + { + using WebApplicationFactory factory = new(); + + McpTokenService? tokenService = factory.Services.GetService(); + Assert.NotNull(tokenService); + + var (token, _) = tokenService.GenerateToken("test-user-id", "testuser", "test@example.com"); + + HttpClient client = factory.CreateClient(); + + // Step 1: Initialize the MCP session + var initRequest = CreateMcpInitializeRequest("/mcp"); + initRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + using HttpResponseMessage initResponse = await client.SendAsync(initRequest); + Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode); + + string sessionId = initResponse.Headers.GetValues("Mcp-Session-Id").First(); + + // Step 2: List tools + var listToolsRequest = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = new StringContent( + """{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""", + Encoding.UTF8, "application/json") + }; + listToolsRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + listToolsRequest.Headers.Accept.ParseAdd("application/json"); + listToolsRequest.Headers.Accept.ParseAdd("text/event-stream"); + listToolsRequest.Headers.Add("Mcp-Session-Id", sessionId); + + using HttpResponseMessage toolsResponse = await client.SendAsync( + listToolsRequest, HttpCompletionOption.ResponseHeadersRead); + Assert.Equal(HttpStatusCode.OK, toolsResponse.StatusCode); + + // SSE streams arrive line-by-line; read until we find the data line or timeout + using Stream stream = await toolsResponse.Content.ReadAsStreamAsync(); + using StreamReader reader = new(stream); + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + string body = ""; + string? line; + while ((line = await reader.ReadLineAsync(cts.Token)) is not null) + { + body += line + "\n"; + if (body.Contains("search_book_content") && body.Contains("get_chapter_list")) + break; + } + + // The MCP C# SDK converts PascalCase method names to snake_case for the wire protocol + Assert.Contains("search_book_content", body); + Assert.Contains("get_chapter_list", body); + } + + private static HttpRequestMessage CreateMcpInitializeRequest(string path) + { + var request = new HttpRequestMessage(HttpMethod.Post, path) + { + Content = new StringContent( + """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "1.0" } + } + } + """, + Encoding.UTF8, "application/json") + }; + // MCP Streamable HTTP transport requires both content types in Accept + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.Accept.ParseAdd("text/event-stream"); + return request; + } +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 07e60731..f90a8b7a 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -20,6 +20,11 @@ public sealed class WebApplicationFactory : WebApplicationFactory protected override void ConfigureWebHost(IWebHostBuilder builder) { + // Inject a stable test signing key so MCP services are registered during + // service registration in Program.cs (which reads configuration["Mcp:SigningKey"] + // before builder.Build() is called — ConfigureAppConfiguration fires too late). + builder.UseSetting("Mcp:SigningKey", "TestOnly-EssentialCSharp-MCP-SigningKey-For-Integration-Tests!"); + builder.ConfigureServices(services => { ServiceDescriptor? dbContextDescriptor = services.SingleOrDefault( diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs index e362bf1c..e52002a4 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs @@ -26,6 +26,8 @@ public static class ManageNavPages public static string Referrals => "Referrals"; + public static string McpAccess => "McpAccess"; + public static string? IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index); public static string? EmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, Email); @@ -44,6 +46,8 @@ public static class ManageNavPages public static string? ReferralsNavClass(ViewContext viewContext) => PageNavClass(viewContext, Referrals); + public static string? McpAccessNavClass(ViewContext viewContext) => PageNavClass(viewContext, McpAccess); + public static string? PageNavClass(ViewContext viewContext, string page) { string? activePage = viewContext.ViewData["ActivePage"] as string diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml new file mode 100644 index 00000000..9ef490ac --- /dev/null +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml @@ -0,0 +1,74 @@ +@page +@model McpAccessModel +@{ + ViewData["Title"] = "MCP Access"; + ViewData["ActivePage"] = ManageNavPages.McpAccess; +} + +

@ViewData["Title"]

+ + + +
+

+ The Model Context Protocol (MCP) lets AI tools like GitHub Copilot and Claude access + Essential C# book content directly. Generate a personal access token below and add it to your MCP client configuration. +

+ + @if (!Model.McpEnabled) + { +
MCP is not currently enabled on this server.
+ } + else + { +
+ +
+ + @if (Model.GeneratedToken is not null) + { +
+

Your MCP Token

+
+ Copy this token now. It will not be shown again after you leave this page. +
+
+ + +
+

Expires: @Model.TokenExpiresAt?.ToString("MMMM d, yyyy")

+ +

MCP Client Configuration

+

Add the following to your MCP client (e.g. .vscode/mcp.json or claude_desktop_config.json):

+
{
+  "essentialcsharp": {
+    "url": "@(HttpContext.Request.Scheme)://@(HttpContext.Request.Host)/mcp",
+    "headers": {
+      "Authorization": "Bearer @Model.GeneratedToken"
+    }
+  }
+}
+ +

MCP Inspector

+

To test locally, run npx @@modelcontextprotocol/inspector, set the URL to @(HttpContext.Request.Scheme)://@(HttpContext.Request.Host)/mcp, and add an Authorization header with value Bearer <token>.

+
+ } + } +
+ +@section Scripts { + + +} diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs new file mode 100644 index 00000000..1e9bbd7d --- /dev/null +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs @@ -0,0 +1,57 @@ +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage; + +public class McpAccessModel : PageModel +{ + private readonly McpTokenService? _McpTokenService; + private readonly UserManager _UserManager; + + [TempData] + public string? StatusMessage { get; set; } + + public string? GeneratedToken { get; private set; } + + public DateTime? TokenExpiresAt { get; private set; } + + public bool McpEnabled => _McpTokenService is not null; + + public McpAccessModel(IServiceProvider serviceProvider, UserManager userManager) + { + _McpTokenService = serviceProvider.GetService(); + _UserManager = userManager; + } + + public IActionResult OnGet() => Page(); + + public async Task OnPostAsync() + { + if (_McpTokenService is null) + { + StatusMessage = "Error: MCP is not enabled on this server."; + return Page(); + } + + EssentialCSharpWebUser? user = await _UserManager.GetUserAsync(User); + if (user is null) + { + return NotFound($"Unable to load user with ID '{_UserManager.GetUserId(User)}'."); + } + + string userId = await _UserManager.GetUserIdAsync(user); + string? userName = user.UserName; + string? email = await _UserManager.GetEmailAsync(user); + + // TODO: Implement per-user token tracking and limit to prevent unbounded token generation. + // Store issued jti claims in the database and enforce a maximum active token count per user. + var (token, expiresAt) = _McpTokenService.GenerateToken(userId, userName, email); + GeneratedToken = token; + TokenExpiresAt = expiresAt; + + return Page(); + } +} diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml index 15dfee37..a141f55d 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml @@ -12,5 +12,6 @@ } + diff --git a/EssentialCSharp.Web/Controllers/McpTokenController.cs b/EssentialCSharp.Web/Controllers/McpTokenController.cs new file mode 100644 index 00000000..e9a0a6fb --- /dev/null +++ b/EssentialCSharp.Web/Controllers/McpTokenController.cs @@ -0,0 +1,33 @@ +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace EssentialCSharp.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class McpTokenController(McpTokenService mcpTokenService) : ControllerBase +{ + [HttpPost] + public IActionResult GenerateToken() + { + string? userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new { Error = "User must be logged in to generate an MCP token." }); + } + + string? userName = User.Identity?.Name; + string? email = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; + + var (token, expiresAt) = mcpTokenService.GenerateToken(userId, userName, email); + + return Ok(new + { + Token = token, + ExpiresAt = expiresAt, + Usage = "Add to your MCP client config: { \"url\": \"/mcp\", \"headers\": { \"Authorization\": \"Bearer \" } }" + }); + } +} diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index 519a7c14..0acef5e0 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -48,6 +48,8 @@ + + diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 5df9f38c..19bc5adf 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -9,7 +9,9 @@ using EssentialCSharp.Web.Middleware; using EssentialCSharp.Web.Services; using EssentialCSharp.Web.Services.Referrals; +using EssentialCSharp.Web.Tools; using Mailjet.Client; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; @@ -162,12 +164,42 @@ private static void Main(string[] args) builder.Services.AddAzureOpenAIServices(configuration); } + // Add MCP server with JWT bearer auth for tool access + var mcpSigningKey = configuration["Mcp:SigningKey"]; + if (!string.IsNullOrEmpty(mcpSigningKey)) + { + var mcpTokenService = new McpTokenService(configuration); + builder.Services.AddSingleton(mcpTokenService); + + builder.Services.AddAuthentication() + .AddJwtBearer("McpBearer", options => + { + options.TokenValidationParameters = mcpTokenService.GetTokenValidationParameters(); + }); + + builder.Services.AddAuthorization(options => + options.AddPolicy("McpPolicy", policy => + policy.AddAuthenticationSchemes("McpBearer") + .RequireAuthenticatedUser())); + + builder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools(); + } + else + { + initialLogger.LogWarning("Mcp:SigningKey not configured. MCP server will be disabled."); + } + // Add Rate Limiting for API endpoints builder.Services.AddRateLimiter(options => { // Global rate limiter for authenticated users by username, anonymous by IP options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => { + if (httpContext.Request.Path.StartsWithSegments("/.well-known")) + return RateLimitPartition.GetNoLimiter("well-known"); + var partitionKey = httpContext.User.Identity?.IsAuthenticated == true ? httpContext.User.Identity.Name ?? "unknown-user" : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; @@ -202,12 +234,23 @@ private static void Main(string[] args) // Custom response when rate limit is exceeded options.OnRejected = async (context, cancellationToken) => { - if (context.HttpContext.Request.Path.StartsWithSegments("/.well-known")) + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.HttpContext.Response.Headers.RetryAfter = "60"; + if (context.HttpContext.Request.Path.StartsWithSegments("/mcp")) { + context.HttpContext.Response.ContentType = "application/json"; + var mcpErrorResponse = new + { + jsonrpc = "2.0", + error = new { code = -32000, message = "Rate limit exceeded. Please wait before sending another request." }, + id = (object?)null + }; + await context.HttpContext.Response.WriteAsync( + System.Text.Json.JsonSerializer.Serialize(mcpErrorResponse), + cancellationToken); return; } - context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; - context.HttpContext.Response.Headers.RetryAfter = "60"; + if (context.HttpContext.Request.Path.StartsWithSegments("/api/chat")) { // Custom rejection handling logic @@ -297,15 +340,21 @@ await context.HttpContext.Response.WriteAsync( app.UseRouting(); app.UseAuthentication(); - app.UseAuthorization(); app.UseRateLimiter(); + app.UseAuthorization(); + app.UseMiddleware(); app.MapRazorPages(); app.MapDefaultControllerRoute(); + if (!string.IsNullOrEmpty(configuration["Mcp:SigningKey"])) + { + app.MapMcp("/mcp").RequireAuthorization("McpPolicy"); + } + app.MapFallbackToController("Index", "Home"); // Generate sitemap.xml at startup diff --git a/EssentialCSharp.Web/Properties/launchSettings.json b/EssentialCSharp.Web/Properties/launchSettings.json index dc0f481a..87f06b28 100644 --- a/EssentialCSharp.Web/Properties/launchSettings.json +++ b/EssentialCSharp.Web/Properties/launchSettings.json @@ -15,7 +15,6 @@ "applicationUrl": "https://localhost:7184;http://localhost:5184", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", - // https://docs.microsoft.com/en-us/aspnet/core/mvc/views/view-compilation?view=aspnetcore-3.1&tabs=visual-studio "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" } }, diff --git a/EssentialCSharp.Web/Services/McpTokenService.cs b/EssentialCSharp.Web/Services/McpTokenService.cs new file mode 100644 index 00000000..3f0c5d76 --- /dev/null +++ b/EssentialCSharp.Web/Services/McpTokenService.cs @@ -0,0 +1,68 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace EssentialCSharp.Web.Services; + +// TODO: Track issued jti claims in the database to enable per-token revocation +// TODO: Add a user-facing revocation UI on the MCP Access page +// TODO: Consider migration to MCP SDK's native OAuth 2.0 flow for token management +public class McpTokenService +{ + private readonly string _SigningKey; + private readonly string _Issuer; + private readonly string _Audience; + private readonly int _ExpirationDays; + + public McpTokenService(IConfiguration configuration) + { + _SigningKey = configuration["Mcp:SigningKey"] + ?? throw new InvalidOperationException("Mcp:SigningKey is not configured. Set it via user-secrets or environment variables."); + _Issuer = configuration["Mcp:Issuer"] ?? "EssentialCSharp"; + _Audience = configuration["Mcp:Audience"] ?? "EssentialCSharp.Mcp"; + _ExpirationDays = int.TryParse(configuration["Mcp:TokenExpirationDays"], out int days) ? days : 7; + } + + public (string Token, DateTime ExpiresAt) GenerateToken(string userId, string? userName, string? email) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_SigningKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var expiresAt = DateTime.UtcNow.AddDays(_ExpirationDays); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + if (!string.IsNullOrEmpty(userName)) + { + claims.Add(new Claim(JwtRegisteredClaimNames.Name, userName)); + } + if (!string.IsNullOrEmpty(email)) + { + claims.Add(new Claim(JwtRegisteredClaimNames.Email, email)); + } + + var token = new JwtSecurityToken( + issuer: _Issuer, + audience: _Audience, + claims: claims, + expires: expiresAt, + signingCredentials: credentials); + + return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt); + } + + public TokenValidationParameters GetTokenValidationParameters() => new() + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = _Issuer, + ValidAudience = _Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_SigningKey)), + }; +} diff --git a/EssentialCSharp.Web/Tools/BookSearchTool.cs b/EssentialCSharp.Web/Tools/BookSearchTool.cs new file mode 100644 index 00000000..2bd11152 --- /dev/null +++ b/EssentialCSharp.Web/Tools/BookSearchTool.cs @@ -0,0 +1,93 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text; +using EssentialCSharp.Chat.Common.Services; +using EssentialCSharp.Web.Services; +using ModelContextProtocol.Server; + +namespace EssentialCSharp.Web.Tools; + +[McpServerToolType] +public sealed class BookSearchTool +{ + private readonly AISearchService? _SearchService; + private readonly ISiteMappingService _SiteMappingService; + + public BookSearchTool(IServiceProvider serviceProvider, ISiteMappingService siteMappingService) + { + _SearchService = serviceProvider.GetService(); + _SiteMappingService = siteMappingService; + } + + [McpServerTool, Description("Search the Essential C# book content using semantic vector search. Returns relevant text chunks with chapter and heading context. Use this to find information about C# programming concepts covered in the book.")] + public async Task SearchBookContent( + [Description("The search query describing the C# concept or topic to find in the book.")] string query, + CancellationToken cancellationToken = default) + { + if (_SearchService is null) + { + return "Book search is not available in this environment (AI services are not configured)."; + } + + var results = await _SearchService.ExecuteVectorSearch(query); + + var sb = new StringBuilder(); + int resultCount = 0; + + await foreach (var result in results.WithCancellation(cancellationToken)) + { + resultCount++; + sb.AppendLine(CultureInfo.InvariantCulture, $"--- Result {resultCount} (Score: {result.Score:F4}) ---"); + + if (result.Record.ChapterNumber.HasValue) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"Chapter: {result.Record.ChapterNumber}"); + } + if (!string.IsNullOrEmpty(result.Record.Heading)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"Section: {result.Record.Heading}"); + } + + sb.AppendLine(); + sb.AppendLine(result.Record.ChunkText); + sb.AppendLine(); + } + + if (resultCount == 0) + { + return "No results found for the given query."; + } + + return sb.ToString(); + } + + [McpServerTool, Description("Get the table of contents for the Essential C# book, listing all chapters and their sections with navigation links.")] + public string GetChapterList() + { + var tocData = _SiteMappingService.GetTocData(); + + var sb = new StringBuilder(); + sb.AppendLine("# Essential C# - Table of Contents"); + sb.AppendLine(); + + foreach (var chapter in tocData) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"## {chapter.Title}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Link: {chapter.Href}"); + + foreach (var section in chapter.Items) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" - {section.Title} ({section.Href})"); + + foreach (var subsection in section.Items) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" - {subsection.Title} ({subsection.Href})"); + } + } + + sb.AppendLine(); + } + + return sb.ToString(); + } +} diff --git a/EssentialCSharp.Web/appsettings.Development.json b/EssentialCSharp.Web/appsettings.Development.json index f7e1d576..8aa12ece 100644 --- a/EssentialCSharp.Web/appsettings.Development.json +++ b/EssentialCSharp.Web/appsettings.Development.json @@ -11,5 +11,8 @@ }, "SiteSettings": { "BaseUrl": "https://localhost:7184" + }, + "Mcp": { + "SigningKey": "DevOnly-EssentialCSharp-MCP-SigningKey-Change-In-Prod-32chars!" } } diff --git a/EssentialCSharp.Web/appsettings.json b/EssentialCSharp.Web/appsettings.json index 1944d305..acc4c65e 100644 --- a/EssentialCSharp.Web/appsettings.json +++ b/EssentialCSharp.Web/appsettings.json @@ -30,5 +30,10 @@ }, "TryDotNet": { "Origin": "" + }, + "Mcp": { + "Issuer": "EssentialCSharp", + "Audience": "EssentialCSharp.Mcp", + "TokenExpirationDays": 30 } } \ No newline at end of file