From 8bd3e2fd145f9b8449c458e33a93dbedda602840 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 24 Feb 2026 21:40:38 -0800 Subject: [PATCH 01/14] feat: Add MCP server with JWT authentication - Add McpTokenService with JWT token generation/validation - Add McpTokenController for token management UI - Add MCP Access page to account management - Configure MCP server with BookSearchTool - Update ModelContextProtocol packages to 0.9.0-preview.2 - Add Microsoft.AspNetCore.Authentication.JwtBearer - Fix MCP client API: use concrete McpClient type - Add toolCallDepth guard to prevent infinite recursion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 + .../Services/AIChatService.cs | 104 ++++++++++----- EssentialCSharp.Web.Tests/McpTests.cs | 119 ++++++++++++++++++ .../WebApplicationFactory.cs | 5 + .../Pages/Account/Manage/ManageNavPages.cs | 4 + .../Pages/Account/Manage/McpAccess.cshtml | 74 +++++++++++ .../Pages/Account/Manage/McpAccess.cshtml.cs | 57 +++++++++ .../Pages/Account/Manage/_ManageNav.cshtml | 1 + .../Controllers/McpTokenController.cs | 33 +++++ .../EssentialCSharp.Web.csproj | 2 + EssentialCSharp.Web/Program.cs | 57 ++++++++- .../Properties/launchSettings.json | 1 - .../Services/McpTokenService.cs | 68 ++++++++++ EssentialCSharp.Web/Tools/BookSearchTool.cs | 93 ++++++++++++++ .../appsettings.Development.json | 3 + EssentialCSharp.Web/appsettings.json | 5 + 16 files changed, 594 insertions(+), 33 deletions(-) create mode 100644 EssentialCSharp.Web.Tests/McpTests.cs create mode 100644 EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml create mode 100644 EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs create mode 100644 EssentialCSharp.Web/Controllers/McpTokenController.cs create mode 100644 EssentialCSharp.Web/Services/McpTokenService.cs create mode 100644 EssentialCSharp.Web/Tools/BookSearchTool.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index f10bbf99..30a1e4fc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -52,6 +52,7 @@ + diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index 8dfab8c4..156b56cf 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, @@ -99,7 +99,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; } @@ -143,7 +143,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)) @@ -160,8 +161,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) { @@ -191,7 +195,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. @@ -244,7 +249,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; } @@ -258,7 +263,7 @@ private static async Task CreateResponseOptionsAsync( string? previousResponseId = null, IEnumerable? tools = null, ResponseReasoningEffortLevel? reasoningEffortLevel = null, - IMcpClient? mcpClient = null, + McpClient? mcpClient = null, CancellationToken cancellationToken = default ) { @@ -282,7 +287,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()))); @@ -313,41 +319,83 @@ 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 = !string.IsNullOrWhiteSpace(systemPrompt) ? 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 = systemContext is not null ? [ResponseItem.CreateSystemMessageItem(systemContext), ResponseItem.CreateUserMessageItem(prompt)] : [ResponseItem.CreateUserMessageItem(prompt)]; #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 287150ee..8e03242c 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -28,6 +28,11 @@ public Task InitializeAsync() 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 d9cff8e8..7932dbef 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -71,6 +71,8 @@ + + diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index ba88dc5a..35b5fc0e 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -8,7 +8,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; @@ -245,12 +247,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"; @@ -292,12 +324,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 @@ -445,15 +488,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 d54a4f46..bc71b1da 100644 --- a/EssentialCSharp.Web/appsettings.json +++ b/EssentialCSharp.Web/appsettings.json @@ -29,5 +29,10 @@ }, "TryDotNet": { "Origin": "" + }, + "Mcp": { + "Issuer": "EssentialCSharp", + "Audience": "EssentialCSharp.Mcp", + "TokenExpirationDays": 30 } } \ No newline at end of file From 56f2472a8a3a38333748c6c584a2e16be99c13dd Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 24 Apr 2026 16:25:41 -0700 Subject: [PATCH 02/14] MCP Server --- Directory.Packages.props | 4 +- EssentialCSharp.Web.Tests/McpTests.cs | 151 +++++-- .../WebApplicationFactory.cs | 5 - .../Data/EssentialCSharpWebContext.cs | 2 + .../Pages/Account/Manage/McpAccess.cshtml | 129 ++++-- .../Pages/Account/Manage/McpAccess.cshtml.cs | 77 ++-- .../Auth/McpApiKeyAuthenticationHandler.cs | 44 +++ .../Controllers/McpTokenController.cs | 51 ++- ...20260424173933_AddMcpApiTokens.Designer.cs | 367 ++++++++++++++++++ .../20260424173933_AddMcpApiTokens.cs | 58 +++ .../EssentialCSharpWebContextModelSnapshot.cs | 58 ++- EssentialCSharp.Web/Models/McpApiToken.cs | 25 ++ EssentialCSharp.Web/Program.cs | 45 +-- .../Services/McpApiTokenService.cs | 102 +++++ .../Services/McpTokenService.cs | 68 ---- EssentialCSharp.Web/Tools/BookSearchTool.cs | 10 +- 16 files changed, 994 insertions(+), 202 deletions(-) create mode 100644 EssentialCSharp.Web/Auth/McpApiKeyAuthenticationHandler.cs create mode 100644 EssentialCSharp.Web/Migrations/20260424173933_AddMcpApiTokens.Designer.cs create mode 100644 EssentialCSharp.Web/Migrations/20260424173933_AddMcpApiTokens.cs create mode 100644 EssentialCSharp.Web/Models/McpApiToken.cs create mode 100644 EssentialCSharp.Web/Services/McpApiTokenService.cs delete mode 100644 EssentialCSharp.Web/Services/McpTokenService.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 30a1e4fc..24095d49 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -50,8 +50,8 @@ - - + + diff --git a/EssentialCSharp.Web.Tests/McpTests.cs b/EssentialCSharp.Web.Tests/McpTests.cs index b246f3d0..7ce20f76 100644 --- a/EssentialCSharp.Web.Tests/McpTests.cs +++ b/EssentialCSharp.Web.Tests/McpTests.cs @@ -1,19 +1,21 @@ using System.Net; using System.Net.Http.Headers; using System.Text; +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; -using System.Threading.Tasks; namespace EssentialCSharp.Web.Tests; -public class McpTests +[NotInParallel("McpTests")] +[ClassDataSource(Shared = SharedType.PerClass)] +public class McpTests(WebApplicationFactory factory) { - [Fact] + [Test] public async Task McpTokenEndpoint_WithoutAuth_Returns401() { - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false @@ -21,42 +23,58 @@ public async Task McpTokenEndpoint_WithoutAuth_Returns401() 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); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } - [Fact] + [Test] 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); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } - [Fact] + [Test] 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"); + // Seed a minimal user row to satisfy the FK on McpApiToken.UserId, then + // create an opaque token via McpApiTokenService (replaces old JWT path). + string testUserId = Guid.NewGuid().ToString(); + string rawToken; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Users.Add(new EssentialCSharpWebUser + { + Id = testUserId, + UserName = "mcp-testuser", + NormalizedUserName = "MCP-TESTUSER", + Email = "mcp-test@example.com", + NormalizedEmail = "MCP-TEST@EXAMPLE.COM", + SecurityStamp = Guid.NewGuid().ToString(), + }); + await db.SaveChangesAsync(); + + var tokenService = scope.ServiceProvider.GetRequiredService(); + (rawToken, _) = await tokenService.CreateTokenAsync(testUserId, "integration-test"); + } HttpClient client = factory.CreateClient(); // Step 1: Initialize the MCP session var initRequest = CreateMcpInitializeRequest("/mcp"); - initRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + initRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); using HttpResponseMessage initResponse = await client.SendAsync(initRequest); - Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode); + await Assert.That(initResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); - string sessionId = initResponse.Headers.GetValues("Mcp-Session-Id").First(); + // Session ID is optional in stateless transport mode + string? sessionId = null; + if (initResponse.Headers.TryGetValues("Mcp-Session-Id", out IEnumerable? sessionIdValues)) + sessionId = sessionIdValues.First(); // Step 2: List tools var listToolsRequest = new HttpRequestMessage(HttpMethod.Post, "/mcp") @@ -65,31 +83,106 @@ public async Task McpEndpoint_WithValidToken_Returns200AndListsTools() """{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""", Encoding.UTF8, "application/json") }; - listToolsRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + listToolsRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); listToolsRequest.Headers.Accept.ParseAdd("application/json"); listToolsRequest.Headers.Accept.ParseAdd("text/event-stream"); - listToolsRequest.Headers.Add("Mcp-Session-Id", sessionId); + if (sessionId is not null) + listToolsRequest.Headers.Add("Mcp-Session-Id", sessionId); using HttpResponseMessage toolsResponse = await client.SendAsync( listToolsRequest, HttpCompletionOption.ResponseHeadersRead); - Assert.Equal(HttpStatusCode.OK, toolsResponse.StatusCode); + await Assert.That(toolsResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); - // SSE streams arrive line-by-line; read until we find the data line or timeout + // Streamable HTTP response: read until we find the tool names or timeout using Stream stream = await toolsResponse.Content.ReadAsStreamAsync(); using StreamReader reader = new(stream); using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); - string body = ""; + var body = new StringBuilder(); 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")) + body.AppendLine(line); + if (body.ToString().Contains("search_book_content") && + body.ToString().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); + string bodyText = body.ToString(); + await Assert.That(bodyText).Contains("search_book_content"); + await Assert.That(bodyText).Contains("get_chapter_list"); + } + + [Test] + public async Task McpEndpoint_WithInvalidToken_Returns401() + { + HttpClient client = factory.CreateClient(); + var request = CreateMcpInitializeRequest("/mcp"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "mcp_invalid_token_that_does_not_exist"); + using HttpResponseMessage response = await client.SendAsync(request); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task McpEndpoint_WithRevokedToken_Returns401() + { + string testUserId = Guid.NewGuid().ToString(); + string rawToken; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Users.Add(new EssentialCSharpWebUser + { + Id = testUserId, + UserName = $"revoked-user-{testUserId[..8]}", + NormalizedUserName = $"REVOKED-USER-{testUserId[..8].ToUpperInvariant()}", + Email = $"revoked-{testUserId[..8]}@example.com", + NormalizedEmail = $"REVOKED-{testUserId[..8].ToUpperInvariant()}@EXAMPLE.COM", + SecurityStamp = Guid.NewGuid().ToString(), + }); + await db.SaveChangesAsync(); + + var tokenService = scope.ServiceProvider.GetRequiredService(); + (rawToken, var entity) = await tokenService.CreateTokenAsync(testUserId, "revoke-test"); + await tokenService.RevokeTokenAsync(entity.Id, testUserId); + } + + HttpClient client = factory.CreateClient(); + var request = CreateMcpInitializeRequest("/mcp"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); + using HttpResponseMessage response = await client.SendAsync(request); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task McpEndpoint_WithExpiredToken_Returns401() + { + string testUserId = Guid.NewGuid().ToString(); + string rawToken; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Users.Add(new EssentialCSharpWebUser + { + Id = testUserId, + UserName = $"expired-user-{testUserId[..8]}", + NormalizedUserName = $"EXPIRED-USER-{testUserId[..8].ToUpperInvariant()}", + Email = $"expired-{testUserId[..8]}@example.com", + NormalizedEmail = $"EXPIRED-{testUserId[..8].ToUpperInvariant()}@EXAMPLE.COM", + SecurityStamp = Guid.NewGuid().ToString(), + }); + await db.SaveChangesAsync(); + + var tokenService = scope.ServiceProvider.GetRequiredService(); + // Create with an expiry in the past (1 second ago) + DateTime pastExpiry = DateTime.UtcNow.AddSeconds(-1); + (rawToken, _) = await tokenService.CreateTokenAsync(testUserId, "expired-test", pastExpiry); + } + + HttpClient client = factory.CreateClient(); + var request = CreateMcpInitializeRequest("/mcp"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); + using HttpResponseMessage response = await client.SendAsync(request); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } private static HttpRequestMessage CreateMcpInitializeRequest(string path) diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 8e03242c..287150ee 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -28,11 +28,6 @@ public Task InitializeAsync() 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/Data/EssentialCSharpWebContext.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs index a64d3b63..aaf81356 100644 --- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs +++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs @@ -1,4 +1,5 @@ using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Models; using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -9,6 +10,7 @@ public class EssentialCSharpWebContext(DbContextOptions(options), IDataProtectionKeyContext { public DbSet DataProtectionKeys { get; set; } = null!; + public DbSet McpApiTokens { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml index 9ef490ac..34f3be48 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml @@ -1,5 +1,6 @@ @page @model McpAccessModel +@using EssentialCSharp.Web.Models @{ ViewData["Title"] = "MCP Access"; ViewData["ActivePage"] = ManageNavPages.McpAccess; @@ -12,33 +13,27 @@

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. + Essential C# book content directly. Create a named API token below and add it to your MCP client configuration.

- @if (!Model.McpEnabled) + @if (Model.GeneratedToken is not null && Model.GeneratedTokenEntity is not null) { -
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")

+
+
Token created: @Model.GeneratedTokenEntity.Name
+

Copy this token now. It will not be shown again.

+
+ + +
+ @if (Model.GeneratedTokenEntity.ExpiresAt.HasValue) + { +

Expires: @Model.GeneratedTokenEntity.ExpiresAt.Value.ToString("MMMM d, yyyy")

+ } +
-

MCP Client Configuration

+
+
MCP Client Configuration
+

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

{
   "essentialcsharp": {
@@ -48,11 +43,87 @@
     }
   }
 }
+

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

+
+
+ } + +
+
Create New Token
+
+
+
+ + + +
+
+ + + +
Leave blank for a non-expiring token. The token expires at end of day (23:59:59) UTC on the selected date.
+
+ +
+
+
-

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>.

+ @if (Model.UserTokens.Count > 0) + { +
+
Your Tokens
+
+ + + + + + + + + + + + + @foreach (McpApiToken token in Model.UserTokens) + { + bool isActive = token.RevokedAt is null + && (token.ExpiresAt is null || token.ExpiresAt > DateTime.UtcNow); + + + + + + + + + } + +
NameCreatedLast UsedExpiresStatus
@token.Name@token.CreatedAt.ToString("MMM d, yyyy")@(token.LastUsedAt?.ToString("MMM d, yyyy") ?? "—")@(token.ExpiresAt?.ToString("MMM d, yyyy") ?? "Never") + @if (token.RevokedAt is not null) + { + Revoked + } + else if (token.ExpiresAt <= DateTime.UtcNow) + { + Expired + } + else + { + Active + } + + @if (isActive) + { +
+ + +
+ } +
- } +
}
@@ -69,6 +140,12 @@ alert('Token copied to clipboard!'); }); } + + document.querySelectorAll('form[data-token-name]').forEach(form => { + form.addEventListener('submit', e => { + if (!confirm(`Revoke token '${form.dataset.tokenName}'?`)) e.preventDefault(); + }); + }); } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs index 1e9bbd7d..4a0c2d97 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs @@ -1,4 +1,6 @@ +using System.ComponentModel.DataAnnotations; using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -6,52 +8,71 @@ namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage; -public class McpAccessModel : PageModel +public class McpAccessModel( + McpApiTokenService tokenService, + UserManager userManager) : 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 McpApiToken? GeneratedTokenEntity { get; private set; } + + public List UserTokens { get; private set; } = []; - public bool McpEnabled => _McpTokenService is not null; + [BindProperty] + [StringLength(256, ErrorMessage = "Token name must be 256 characters or fewer.")] + public string TokenName { get; set; } = "My Token"; - public McpAccessModel(IServiceProvider serviceProvider, UserManager userManager) + [BindProperty] + public DateOnly? ExpiresOn { get; set; } + + public async Task OnGetAsync() { - _McpTokenService = serviceProvider.GetService(); - _UserManager = userManager; + string? userId = userManager.GetUserId(User); + if (userId is null) return Challenge(); + UserTokens = await tokenService.GetUserTokensAsync(userId); + return Page(); } - public IActionResult OnGet() => Page(); - - public async Task OnPostAsync() + public async Task OnPostCreateAsync() { - if (_McpTokenService is null) + string? userId = userManager.GetUserId(User); + if (userId is null) return Challenge(); + + if (string.IsNullOrWhiteSpace(TokenName)) + ModelState.AddModelError(nameof(TokenName), "Token name is required."); + + if (ExpiresOn.HasValue && ExpiresOn.Value < DateOnly.FromDateTime(DateTime.UtcNow)) + ModelState.AddModelError(nameof(ExpiresOn), "Expiry date must be today or in the future."); + + if (!ModelState.IsValid) { - StatusMessage = "Error: MCP is not enabled on this server."; + UserTokens = await tokenService.GetUserTokensAsync(userId); return Page(); } - EssentialCSharpWebUser? user = await _UserManager.GetUserAsync(User); - if (user is null) - { - return NotFound($"Unable to load user with ID '{_UserManager.GetUserId(User)}'."); - } + // Convert date-only boundary to end-of-day UTC instant before persisting + DateTime? expiresAt = ExpiresOn?.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); - string userId = await _UserManager.GetUserIdAsync(user); - string? userName = user.UserName; - string? email = await _UserManager.GetEmailAsync(user); + var (rawToken, entity) = await tokenService.CreateTokenAsync(userId, TokenName.Trim(), expiresAt); + GeneratedToken = rawToken; + GeneratedTokenEntity = entity; + UserTokens = await tokenService.GetUserTokensAsync(userId); + return Page(); + } - // 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; + public async Task OnPostRevokeAsync(Guid tokenId) + { + string? userId = userManager.GetUserId(User); + if (userId is null) return Challenge(); - return Page(); + bool revoked = await tokenService.RevokeTokenAsync(tokenId, userId); + StatusMessage = revoked + ? "Token revoked successfully." + : "Error: Token not found or already revoked."; + + return RedirectToPage(); } } diff --git a/EssentialCSharp.Web/Auth/McpApiKeyAuthenticationHandler.cs b/EssentialCSharp.Web/Auth/McpApiKeyAuthenticationHandler.cs new file mode 100644 index 00000000..cebef627 --- /dev/null +++ b/EssentialCSharp.Web/Auth/McpApiKeyAuthenticationHandler.cs @@ -0,0 +1,44 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Auth; + +/// +/// Authenticates MCP requests via opaque "mcp_..." bearer tokens stored in the database. +/// Reads Authorization: Bearer mcp_... header, validates via McpApiTokenService, and +/// builds a ClaimsPrincipal with NameIdentifier set to the token owner's user ID. +/// +public class McpApiKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + McpApiTokenService tokenService) + : AuthenticationHandler(options, logger, encoder) +{ + protected override async Task HandleAuthenticateAsync() + { + string? authHeader = Request.Headers.Authorization.FirstOrDefault(); + if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return AuthenticateResult.NoResult(); + + string rawToken = authHeader["Bearer ".Length..].Trim(); + if (!rawToken.StartsWith("mcp_", StringComparison.Ordinal)) + return AuthenticateResult.NoResult(); + + var (token, userId) = await tokenService.ValidateTokenAsync(rawToken, Context.RequestAborted); + if (token is null || userId is null) + return AuthenticateResult.Fail("Invalid or revoked MCP token."); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, userId), + }; + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + return AuthenticateResult.Success(ticket); + } +} diff --git a/EssentialCSharp.Web/Controllers/McpTokenController.cs b/EssentialCSharp.Web/Controllers/McpTokenController.cs index e9a0a6fb..9138f47b 100644 --- a/EssentialCSharp.Web/Controllers/McpTokenController.cs +++ b/EssentialCSharp.Web/Controllers/McpTokenController.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,27 +8,57 @@ namespace EssentialCSharp.Web.Controllers; [ApiController] [Route("api/[controller]")] [Authorize] -public class McpTokenController(McpTokenService mcpTokenService) : ControllerBase +public class McpTokenController(McpApiTokenService tokenService) : ControllerBase { + public record CreateTokenRequest(string Name, DateOnly? ExpiresOn = null); + [HttpPost] - public IActionResult GenerateToken() + public async Task CreateToken( + [FromBody] CreateTokenRequest? request, + CancellationToken cancellationToken) { - string? userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + string? userId = User.FindFirst(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; + string name = string.IsNullOrWhiteSpace(request?.Name) ? "default" : request.Name.Trim(); + if (name.Length > 256) + return BadRequest(new { Error = "Token name must be 256 characters or fewer." }); + + DateTime? expiresAt = null; + if (request?.ExpiresOn.HasValue == true) + { + if (request.ExpiresOn.Value < DateOnly.FromDateTime(DateTime.UtcNow)) + return BadRequest(new { Error = "ExpiresOn must be today or in the future." }); + // Convert date-only boundary to end-of-day UTC instant before persisting + expiresAt = request.ExpiresOn.Value.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); + } - var (token, expiresAt) = mcpTokenService.GenerateToken(userId, userName, email); + var (rawToken, entity) = await tokenService.CreateTokenAsync( + userId, name, expiresAt, cancellationToken); return Ok(new { - Token = token, - ExpiresAt = expiresAt, + TokenId = entity.Id, + Token = rawToken, + Name = entity.Name, + ExpiresAt = entity.ExpiresAt, + CreatedAt = entity.CreatedAt, Usage = "Add to your MCP client config: { \"url\": \"/mcp\", \"headers\": { \"Authorization\": \"Bearer \" } }" }); } + + [HttpDelete("{id:guid}")] + public async Task RevokeToken(Guid id, CancellationToken cancellationToken) + { + string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + return Unauthorized(); + + bool revoked = await tokenService.RevokeTokenAsync(id, userId, cancellationToken); + if (!revoked) + return NotFound(new { Error = "Token not found or already revoked." }); + + return NoContent(); + } } diff --git a/EssentialCSharp.Web/Migrations/20260424173933_AddMcpApiTokens.Designer.cs b/EssentialCSharp.Web/Migrations/20260424173933_AddMcpApiTokens.Designer.cs new file mode 100644 index 00000000..d4282936 --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20260424173933_AddMcpApiTokens.Designer.cs @@ -0,0 +1,367 @@ +// +using System; +using EssentialCSharp.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EssentialCSharp.Web.Migrations +{ + [DbContext(typeof(EssentialCSharpWebContext))] + [Migration("20260424173933_AddMcpApiTokens")] + partial class AddMcpApiTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("ReferralCount") + .HasColumnType("int"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.McpApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varbinary(32)"); + + b.Property("UsageCount") + .HasColumnType("bigint"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("McpApiTokens"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.McpApiToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EssentialCSharp.Web/Migrations/20260424173933_AddMcpApiTokens.cs b/EssentialCSharp.Web/Migrations/20260424173933_AddMcpApiTokens.cs new file mode 100644 index 00000000..b447642d --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20260424173933_AddMcpApiTokens.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EssentialCSharp.Web.Migrations +{ + /// + public partial class AddMcpApiTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "McpApiTokens", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + TokenHash = table.Column(type: "varbinary(32)", maxLength: 32, nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + LastUsedAt = table.Column(type: "datetime2", nullable: true), + ExpiresAt = table.Column(type: "datetime2", nullable: true), + RevokedAt = table.Column(type: "datetime2", nullable: true), + UsageCount = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_McpApiTokens", x => x.Id); + table.ForeignKey( + name: "FK_McpApiTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_McpApiTokens_TokenHash", + table: "McpApiTokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_McpApiTokens_UserId", + table: "McpApiTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "McpApiTokens"); + } + } +} diff --git a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs index 7d1e4270..a7f7c2b2 100644 --- a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs +++ b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.6") + .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -96,6 +96,51 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("EssentialCSharp.Web.Models.McpApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varbinary(32)"); + + b.Property("UsageCount") + .HasColumnType("bigint"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("McpApiTokens"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => { b.Property("Id") @@ -252,6 +297,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("EssentialCSharp.Web.Models.McpApiToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/EssentialCSharp.Web/Models/McpApiToken.cs b/EssentialCSharp.Web/Models/McpApiToken.cs new file mode 100644 index 00000000..7a568b3f --- /dev/null +++ b/EssentialCSharp.Web/Models/McpApiToken.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using EssentialCSharp.Web.Areas.Identity.Data; +using Microsoft.EntityFrameworkCore; + +namespace EssentialCSharp.Web.Models; + +[Index(nameof(TokenHash), IsUnique = true)] +[Index(nameof(UserId))] +public class McpApiToken +{ + public Guid Id { get; set; } + public required string UserId { get; set; } + [MaxLength(256)] + public required string Name { get; set; } + // SHA-256 hash stored as varbinary(32) — avoids SQL Server case-insensitive collation issues + [MaxLength(32)] + public required byte[] TokenHash { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastUsedAt { get; set; } + public DateTime? ExpiresAt { get; set; } + public DateTime? RevokedAt { get; set; } + public long UsageCount { get; set; } + public EssentialCSharpWebUser? User { get; set; } +} + diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 35b5fc0e..2ea0d507 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -2,6 +2,7 @@ using EssentialCSharp.Chat.Common.Extensions; using EssentialCSharp.Web.Areas.Identity.Data; using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators; +using EssentialCSharp.Web.Auth; using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Extensions; using EssentialCSharp.Web.Helpers; @@ -10,7 +11,7 @@ using EssentialCSharp.Web.Services.Referrals; using EssentialCSharp.Web.Tools; using Mailjet.Client; -using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; @@ -247,32 +248,21 @@ 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); + // MCP server — always enabled, authenticated via opaque DB-backed tokens. + builder.Services.AddScoped(); - builder.Services.AddAuthentication() - .AddJwtBearer("McpBearer", options => - { - options.TokenValidationParameters = mcpTokenService.GetTokenValidationParameters(); - }); + builder.Services.AddAuthentication() + .AddScheme( + "McpBearer", _ => { }); - builder.Services.AddAuthorization(options => - options.AddPolicy("McpPolicy", policy => - policy.AddAuthenticationSchemes("McpBearer") - .RequireAuthenticatedUser())); + 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."); - } + builder.Services.AddMcpServer() + .WithHttpTransport(options => options.Stateless = true) + .WithTools(); // Add Rate Limiting for API endpoints builder.Services.AddRateLimiter(options => @@ -284,7 +274,7 @@ private static void Main(string[] args) return RateLimitPartition.GetNoLimiter("well-known"); var partitionKey = httpContext.User.Identity?.IsAuthenticated == true - ? httpContext.User.Identity.Name ?? "unknown-user" + ? httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "unknown-user" : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; return RateLimitPartition.GetFixedWindowLimiter( @@ -498,10 +488,7 @@ await context.HttpContext.Response.WriteAsync( app.MapRazorPages(); app.MapDefaultControllerRoute(); - if (!string.IsNullOrEmpty(configuration["Mcp:SigningKey"])) - { - app.MapMcp("/mcp").RequireAuthorization("McpPolicy"); - } + app.MapMcp("/mcp").RequireAuthorization("McpPolicy"); app.MapFallbackToController("Index", "Home"); diff --git a/EssentialCSharp.Web/Services/McpApiTokenService.cs b/EssentialCSharp.Web/Services/McpApiTokenService.cs new file mode 100644 index 00000000..4012cfe0 --- /dev/null +++ b/EssentialCSharp.Web/Services/McpApiTokenService.cs @@ -0,0 +1,102 @@ +using System.Security.Cryptography; +using System.Text; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; + +namespace EssentialCSharp.Web.Services; + +public class McpApiTokenService(EssentialCSharpWebContext db) +{ + /// Returns SHA-256 hash of the raw token as a byte array (varbinary(32)). + public static byte[] HashToken(string rawToken) + => SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)); + + /// Generates a cryptographically random opaque token with "mcp_" prefix. + public static string GenerateRawToken() + => "mcp_" + Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(32)); + + /// + /// Creates a new named API token for the specified user. + /// Returns the raw token (shown once — never stored). + /// + public async Task<(string RawToken, McpApiToken Entity)> CreateTokenAsync( + string userId, + string name, + DateTime? expiresAt = null, + CancellationToken cancellationToken = default) + { + string raw = GenerateRawToken(); + var entity = new McpApiToken + { + UserId = userId, + Name = name, + TokenHash = HashToken(raw), + CreatedAt = DateTime.UtcNow, + ExpiresAt = expiresAt, + }; + db.McpApiTokens.Add(entity); + await db.SaveChangesAsync(cancellationToken); + return (raw, entity); + } + + /// + /// Revokes a token by ID. Validates ownership to prevent cross-user revocation. + /// Returns false if token not found or user doesn't own it. + /// + public async Task RevokeTokenAsync( + Guid tokenId, + string userId, + CancellationToken cancellationToken = default) + { + int rows = await db.McpApiTokens + .Where(t => t.Id == tokenId && t.UserId == userId && t.RevokedAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.RevokedAt, DateTime.UtcNow), cancellationToken); + return rows > 0; + } + + /// Returns all tokens for the user (metadata only — no raw values). + public Task> GetUserTokensAsync( + string userId, + CancellationToken cancellationToken = default) + => db.McpApiTokens + .Where(t => t.UserId == userId) + .OrderByDescending(t => t.CreatedAt) + .AsNoTracking() + .ToListAsync(cancellationToken); + + /// + /// Validates a raw token string. Updates LastUsedAt and UsageCount on success. + /// Returns (token, userId) on success, or (null, null) on failure. + /// + public async Task<(McpApiToken? Token, string? UserId)> ValidateTokenAsync( + string rawToken, + CancellationToken cancellationToken = default) + { + byte[] hash = HashToken(rawToken); + DateTime now = DateTime.UtcNow; + + // Initial read — early exit for completely unknown tokens before hitting the update round-trip + McpApiToken? token = await db.McpApiTokens + .AsNoTracking() + .FirstOrDefaultAsync(t => t.TokenHash == hash, cancellationToken); + + if (token is null) return (null, null); + + // Atomic guard: re-checks validity at the moment of update so a concurrent + // revoke between the read above and this update cannot slip through (TOCTOU fix). + int rows = await db.McpApiTokens + .Where(t => t.Id == token.Id + && t.RevokedAt == null + && (t.ExpiresAt == null || t.ExpiresAt > now)) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.LastUsedAt, now) + .SetProperty(t => t.UsageCount, t => t.UsageCount + 1), + cancellationToken); + + if (rows == 0) return (null, null); + + return (token, token.UserId); + } +} diff --git a/EssentialCSharp.Web/Services/McpTokenService.cs b/EssentialCSharp.Web/Services/McpTokenService.cs deleted file mode 100644 index 3f0c5d76..00000000 --- a/EssentialCSharp.Web/Services/McpTokenService.cs +++ /dev/null @@ -1,68 +0,0 @@ -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 index 2bd11152..af3ee3c4 100644 --- a/EssentialCSharp.Web/Tools/BookSearchTool.cs +++ b/EssentialCSharp.Web/Tools/BookSearchTool.cs @@ -19,7 +19,8 @@ public BookSearchTool(IServiceProvider serviceProvider, ISiteMappingService site _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.")] + [McpServerTool(Title = "Search Book Content", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + 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) @@ -29,12 +30,12 @@ public async Task SearchBookContent( return "Book search is not available in this environment (AI services are not configured)."; } - var results = await _SearchService.ExecuteVectorSearch(query); + var results = await _SearchService.ExecuteVectorSearch(query, cancellationToken: cancellationToken); var sb = new StringBuilder(); int resultCount = 0; - await foreach (var result in results.WithCancellation(cancellationToken)) + foreach (var result in results) { resultCount++; sb.AppendLine(CultureInfo.InvariantCulture, $"--- Result {resultCount} (Score: {result.Score:F4}) ---"); @@ -61,7 +62,8 @@ public async Task SearchBookContent( return sb.ToString(); } - [McpServerTool, Description("Get the table of contents for the Essential C# book, listing all chapters and their sections with navigation links.")] + [McpServerTool(Title = "Get Chapter List", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + 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(); From 2e3ab5124d07ccd4cd918236034d5454fada5982 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 24 Apr 2026 17:05:56 -0700 Subject: [PATCH 03/14] docs --- .../Pages/Account/Manage/McpAccess.cshtml | 3 +- .../Controllers/McpSetupController.cs | 21 + .../Views/McpSetup/Index.cshtml | 431 ++++++++++++++++++ .../Views/Shared/_Layout.cshtml | 3 + 4 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 EssentialCSharp.Web/Controllers/McpSetupController.cs create mode 100644 EssentialCSharp.Web/Views/McpSetup/Index.cshtml diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml index 34f3be48..463ebf15 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml @@ -14,6 +14,7 @@

The Model Context Protocol (MCP) lets AI tools like GitHub Copilot and Claude access Essential C# book content directly. Create a named API token below and add it to your MCP client configuration. + Setup guide & FAQ →

@if (Model.GeneratedToken is not null && Model.GeneratedTokenEntity is not null) @@ -43,7 +44,7 @@ } } } -

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

+

See the AI Tools setup guide for per-client configuration snippets.

} diff --git a/EssentialCSharp.Web/Controllers/McpSetupController.cs b/EssentialCSharp.Web/Controllers/McpSetupController.cs new file mode 100644 index 00000000..e5fd6346 --- /dev/null +++ b/EssentialCSharp.Web/Controllers/McpSetupController.cs @@ -0,0 +1,21 @@ +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace EssentialCSharp.Web.Controllers; + +[AllowAnonymous] +public class McpSetupController : BaseController +{ + public McpSetupController(IRouteConfigurationService routeConfigurationService, IHttpContextAccessor httpContextAccessor) + : base(routeConfigurationService, httpContextAccessor) + { + } + + [Route("/mcp-setup")] + public IActionResult Index() + { + ViewBag.PageTitle = "MCP Setup"; + return View(); + } +} diff --git a/EssentialCSharp.Web/Views/McpSetup/Index.cshtml b/EssentialCSharp.Web/Views/McpSetup/Index.cshtml new file mode 100644 index 00000000..8876ec8b --- /dev/null +++ b/EssentialCSharp.Web/Views/McpSetup/Index.cshtml @@ -0,0 +1,431 @@ +@using Microsoft.AspNetCore.Identity +@using EssentialCSharp.Web.Areas.Identity.Data +@inject SignInManager SignInManager +@{ + ViewData["Title"] = "AI Tools Setup"; + string mcpUrl = $"{Context.Request.Scheme}://{Context.Request.Host}/mcp"; + bool isSignedIn = SignInManager.IsSignedIn(User); +} + +
+ + +
+

Use AI Tools with Essential C#

+

+ Connect GitHub Copilot, Claude, Cursor, or any MCP-compatible AI client directly to the Essential C# + book content. Search chapters by concept or browse the full table of contents — right from your editor. +

+ @if (isSignedIn) + { + Get your token → + } + else + { + Sign in to get a token → + } + Setup guide ↓ +
+ + +
+

Setup Guide

+

Three steps to connect any MCP-compatible client.

+ +
Step 1 — Create an account
+

+ @if (isSignedIn) + { + ✔ You're signed in. + } + else + { + + Register or + sign in to your Essential C# account. + + } +

+ +
Step 2 — Generate a token
+

+ Go to MCP Access under your account settings. + Enter a name for the token (e.g. "VS Code"), choose an optional expiry date, and click + Create Token. Copy the token — it won't be shown again. +

+ +
Step 3 — Configure your client
+ +
+ + +
+

+ +

+
+
+

+ Add to your VS Code user mcp.json so the server is available across all workspaces. + Open it via Ctrl+Shift+PMCP: Open User Configuration: +

+
    +
  • Windows: %APPDATA%\Code\User\mcp.json
  • +
  • macOS: ~/Library/Application Support/Code/User/mcp.json
  • +
  • Linux: ~/.config/Code/User/mcp.json
  • +
+
+
{
+  "inputs": [
+    {
+      "type": "promptString",
+      "id": "ecs-token",
+      "description": "Essential C# MCP token (from essentialcsharp.com)",
+      "password": true
+    }
+  ],
+  "servers": {
+    "essentialcsharp": {
+      "type": "http",
+      "url": "@mcpUrl",
+      "headers": {
+        "Authorization": "Bearer ${input:ecs-token}"
+      }
+    }
+  }
+}
+ +
+

+ VS Code will prompt you to enter your token once and store it securely — it's never written + to disk in plain text. + To scope to a single workspace instead, add the same servers block (without the outer + mcp wrapper) to .vscode/mcp.json in your project root. +

+
+
+
+ + +
+

+ +

+
+
+

+ Add to ~/.copilot/mcp-config.json (create the file if it doesn't exist) + to make the server available in every Copilot CLI session: +

+
+
{
+  "mcpServers": {
+    "essentialcsharp": {
+      "type": "http",
+      "url": "@mcpUrl",
+      "headers": {
+        "Authorization": "Bearer <your-token>"
+      }
+    }
+  }
+}
+ +
+

+ Replace <your-token> with the token you created in Step 2. + To scope to a single project instead, add the same config to .mcp.json + in your project root. +

+
+
+
+ + +
+

+ +

+
+
+

Edit your Claude Desktop config file:

+
    +
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
  • +
  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • +
+
+
{
+  "mcpServers": {
+    "essentialcsharp": {
+      "url": "@mcpUrl",
+      "headers": {
+        "Authorization": "Bearer <your-token>"
+      }
+    }
+  }
+}
+ +
+

+ Replace <your-token> with the token you created in Step 2. + If your version of Claude Desktop doesn't support remote MCP yet, use + npx -y mcp-remote @mcpUrl with the + MCP_REMOTE_HEADER_AUTHORIZATION env var set to your Bearer token. +

+
+
+
+ + +
+

+ +

+
+
+

Run this command once to register the server for all your projects:

+
+
claude mcp add --transport http essentialcsharp @mcpUrl \
+  --scope user \
+  --header "Authorization: Bearer <your-token>"
+ +
+

+ Replace <your-token> with the token you created in Step 2. + Omit --scope user to limit to the current project only, or use + --scope project to commit the server to .mcp.json for your team. +

+
+
+
+ + +
+

+ +

+
+
+

+ Add to ~/.cursor/mcp.json to make the server available in all Cursor workspaces + (create the file if it doesn't exist): +

+
+
{
+  "mcpServers": {
+    "essentialcsharp": {
+      "url": "@mcpUrl",
+      "headers": {
+        "Authorization": "Bearer <your-token>"
+      }
+    }
+  }
+}
+ +
+

+ Replace <your-token> with the token you created in Step 2. + To scope to a single project instead, use .cursor/mcp.json in your project root. +

+
+
+
+ +
+
+ +
+
+

Available Tools

+ +
+

Once connected, your AI client has access to the following tools:

+ +
+ + + search_book_content + Semantic search over book chapters + +
+

+ Semantic vector search over all Essential C# book chapters. Returns the most relevant text + chunks with chapter number, section heading, and relevance score. Use this to ask questions + like "How does async/await work in C#?" or "What is pattern matching?" +

+
+
Input
+
query — a natural-language description of the C# concept to find
+
Returns
+
Ranked text chunks with chapter, section, and score context
+
+
+
+ +
+ + + get_chapter_list + Full table of contents + +
+

+ Returns the full table of contents for Essential C# — all chapters and their sections with + navigation links. Useful for orientation or when you want to explore a specific chapter. +

+
+
Input
+
None
+
Returns
+
Markdown table of contents with chapter titles, sections, and links
+
+
+
+
+ + +
+

Troubleshooting & FAQ

+ +
+ +
+

+ +

+
+
+
    +
  • Make sure you've included the Authorization: Bearer mcp_... header in your client config.
  • +
  • Check that the token hasn't been revoked or expired on the MCP Access page.
  • +
  • Token values are case-sensitive — copy/paste carefully.
  • +
  • The token must start with mcp_. Tokens from other systems (e.g. GitHub tokens) won't work.
  • +
+
+
+
+ +
+

+ +

+
+
+

+ Yes — create a dedicated token for each client. For example: one token named + "VS Code", a separate one named "Claude Desktop", and so on. This way, if a token is ever + compromised or you stop using a particular client, you can revoke just that token without + disrupting your other tools. Tokens are free and easy to create, so there's no reason to share them. +

+
+
+
+ +
+

+ +

+
+
+

+ Token values are stored as a one-way hash and cannot be recovered. Revoke the old token + on the MCP Access page and create a new one. +

+
+
+
+ +
+

+ +

+
+
+

+ The Model Context Protocol (MCP) + is an open standard that lets AI assistants call external tools and data sources in a + structured, secure way. It's supported by GitHub Copilot, Claude, Cursor, and many + other AI coding tools. This site exposes its book content as an MCP server so AI + assistants can answer C# questions with accurate, up-to-date content from the book. +

+
+
+
+ +
+
+ +
+ +@section Scripts { + + +} diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 899b3e45..5a82306c 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -113,6 +113,9 @@ + From 6c8126cc800151fe797a5108833dcdce0dd28f0a Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 24 Apr 2026 18:49:26 -0700 Subject: [PATCH 04/14] Add 15 MCP tools for book content, listings, guidelines, and search - Add IGuidelinesService + GuidelinesService (singleton) for guidelines.json - Add BookListingTool: GetListingSourceCode, SearchListingsByCode - Add BookGuidelinesTool: GetCSharpGuidelines, GetGuidelinesByTopic - Add BookContentTool: GetSectionContent, GetListingWithContext, GetNavigationContext, GetChapterSummary - Extend BookSearchTool: GetChapterSections, GetDirectContentUrl, GetBookMetadata, LookupConcept, CheckTopicCoverage, FindBookHelpForDiagnostic, FindRelatedSections - Register all new tools in Program.cs MCP block Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/AIChatService.cs | 15 +- EssentialCSharp.Web.Tests/McpTests.cs | 12 +- .../Controllers/McpTokenController.cs | 6 +- EssentialCSharp.Web/Program.cs | 7 +- .../Services/GuidelinesService.cs | 16 + .../Services/IGuidelinesService.cs | 6 + EssentialCSharp.Web/Tools/BookContentTool.cs | 406 ++++++++++++++++++ .../Tools/BookGuidelinesTool.cs | 133 ++++++ EssentialCSharp.Web/Tools/BookListingTool.cs | 100 +++++ EssentialCSharp.Web/Tools/BookSearchTool.cs | 393 +++++++++++++++++ 10 files changed, 1074 insertions(+), 20 deletions(-) create mode 100644 EssentialCSharp.Web/Services/GuidelinesService.cs create mode 100644 EssentialCSharp.Web/Services/IGuidelinesService.cs create mode 100644 EssentialCSharp.Web/Tools/BookContentTool.cs create mode 100644 EssentialCSharp.Web/Tools/BookGuidelinesTool.cs create mode 100644 EssentialCSharp.Web/Tools/BookListingTool.cs diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index 156b56cf..9c3298dd 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -354,22 +354,17 @@ private static async Task CreateResponseOptionsAsync( Dictionary arguments = []; foreach (var kvp in jsonArguments) { - if (kvp.Value is System.Text.Json.JsonElement jsonElement) - { - arguments[kvp.Key] = jsonElement.ValueKind switch + arguments[kvp.Key] = kvp.Value is System.Text.Json.JsonElement jsonElement + ? 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; - } + _ => (object?)jsonElement.ToString() + } + : kvp.Value; } var toolResult = await mcpClient.CallToolAsync( diff --git a/EssentialCSharp.Web.Tests/McpTests.cs b/EssentialCSharp.Web.Tests/McpTests.cs index 7ce20f76..d42f3f4c 100644 --- a/EssentialCSharp.Web.Tests/McpTests.cs +++ b/EssentialCSharp.Web.Tests/McpTests.cs @@ -31,7 +31,7 @@ public async Task McpEndpoint_WithoutToken_Returns401() { HttpClient client = factory.CreateClient(); - var request = CreateMcpInitializeRequest("/mcp"); + using var request = CreateMcpInitializeRequest("/mcp"); using HttpResponseMessage response = await client.SendAsync(request); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); @@ -65,7 +65,7 @@ public async Task McpEndpoint_WithValidToken_Returns200AndListsTools() HttpClient client = factory.CreateClient(); // Step 1: Initialize the MCP session - var initRequest = CreateMcpInitializeRequest("/mcp"); + using var initRequest = CreateMcpInitializeRequest("/mcp"); initRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); using HttpResponseMessage initResponse = await client.SendAsync(initRequest); @@ -77,7 +77,7 @@ public async Task McpEndpoint_WithValidToken_Returns200AndListsTools() sessionId = sessionIdValues.First(); // Step 2: List tools - var listToolsRequest = new HttpRequestMessage(HttpMethod.Post, "/mcp") + using var listToolsRequest = new HttpRequestMessage(HttpMethod.Post, "/mcp") { Content = new StringContent( """{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""", @@ -116,7 +116,7 @@ public async Task McpEndpoint_WithValidToken_Returns200AndListsTools() public async Task McpEndpoint_WithInvalidToken_Returns401() { HttpClient client = factory.CreateClient(); - var request = CreateMcpInitializeRequest("/mcp"); + using var request = CreateMcpInitializeRequest("/mcp"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "mcp_invalid_token_that_does_not_exist"); using HttpResponseMessage response = await client.SendAsync(request); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); @@ -147,7 +147,7 @@ public async Task McpEndpoint_WithRevokedToken_Returns401() } HttpClient client = factory.CreateClient(); - var request = CreateMcpInitializeRequest("/mcp"); + using var request = CreateMcpInitializeRequest("/mcp"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); using HttpResponseMessage response = await client.SendAsync(request); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); @@ -179,7 +179,7 @@ public async Task McpEndpoint_WithExpiredToken_Returns401() } HttpClient client = factory.CreateClient(); - var request = CreateMcpInitializeRequest("/mcp"); + using var request = CreateMcpInitializeRequest("/mcp"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); using HttpResponseMessage response = await client.SendAsync(request); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); diff --git a/EssentialCSharp.Web/Controllers/McpTokenController.cs b/EssentialCSharp.Web/Controllers/McpTokenController.cs index 9138f47b..6f3b955e 100644 --- a/EssentialCSharp.Web/Controllers/McpTokenController.cs +++ b/EssentialCSharp.Web/Controllers/McpTokenController.cs @@ -26,12 +26,12 @@ public async Task CreateToken( return BadRequest(new { Error = "Token name must be 256 characters or fewer." }); DateTime? expiresAt = null; - if (request?.ExpiresOn.HasValue == true) + if (request?.ExpiresOn is DateOnly expiresOn) { - if (request.ExpiresOn.Value < DateOnly.FromDateTime(DateTime.UtcNow)) + if (expiresOn < DateOnly.FromDateTime(DateTime.UtcNow)) return BadRequest(new { Error = "ExpiresOn must be today or in the future." }); // Convert date-only boundary to end-of-day UTC instant before persisting - expiresAt = request.ExpiresOn.Value.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); + expiresAt = expiresOn.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); } var (rawToken, entity) = await tokenService.CreateTokenAsync( diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 2ea0d507..802d09c0 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -260,9 +260,14 @@ private static void Main(string[] args) policy.AddAuthenticationSchemes("McpBearer") .RequireAuthenticatedUser())); + builder.Services.AddSingleton(); + builder.Services.AddMcpServer() .WithHttpTransport(options => options.Stateless = true) - .WithTools(); + .WithTools() + .WithTools() + .WithTools() + .WithTools(); // Add Rate Limiting for API endpoints builder.Services.AddRateLimiter(options => diff --git a/EssentialCSharp.Web/Services/GuidelinesService.cs b/EssentialCSharp.Web/Services/GuidelinesService.cs new file mode 100644 index 00000000..d6f166c9 --- /dev/null +++ b/EssentialCSharp.Web/Services/GuidelinesService.cs @@ -0,0 +1,16 @@ +using EssentialCSharp.Web.Extensions; + +namespace EssentialCSharp.Web.Services; + +public sealed class GuidelinesService : IGuidelinesService +{ + private readonly IReadOnlyList _guidelines; + + public GuidelinesService(IWebHostEnvironment environment, ILogger logger) + { + FileInfo fileInfo = new(Path.Join(environment.ContentRootPath, "Guidelines", "guidelines.json")); + _guidelines = fileInfo.ReadGuidelineJsonFromInputDirectory(logger) ?? []; + } + + public IReadOnlyList Guidelines => _guidelines; +} diff --git a/EssentialCSharp.Web/Services/IGuidelinesService.cs b/EssentialCSharp.Web/Services/IGuidelinesService.cs new file mode 100644 index 00000000..892d5926 --- /dev/null +++ b/EssentialCSharp.Web/Services/IGuidelinesService.cs @@ -0,0 +1,6 @@ +namespace EssentialCSharp.Web.Services; + +public interface IGuidelinesService +{ + IReadOnlyList Guidelines { get; } +} diff --git a/EssentialCSharp.Web/Tools/BookContentTool.cs b/EssentialCSharp.Web/Tools/BookContentTool.cs new file mode 100644 index 00000000..bd04420d --- /dev/null +++ b/EssentialCSharp.Web/Tools/BookContentTool.cs @@ -0,0 +1,406 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text; +using EssentialCSharp.Chat.Common.Services; +using EssentialCSharp.Web.Extensions; +using EssentialCSharp.Web.Services; +using HtmlAgilityPack; +using ModelContextProtocol.Server; + +namespace EssentialCSharp.Web.Tools; + +[McpServerToolType] +public sealed class BookContentTool +{ + private readonly ISiteMappingService _siteMappingService; + private readonly IListingSourceCodeService _listingService; + private readonly IGuidelinesService _guidelinesService; + private readonly IWebHostEnvironment _environment; + private readonly AISearchService? _searchService; + + public BookContentTool( + ISiteMappingService siteMappingService, + IListingSourceCodeService listingService, + IGuidelinesService guidelinesService, + IWebHostEnvironment environment, + IServiceProvider serviceProvider) + { + _siteMappingService = siteMappingService; + _listingService = listingService; + _guidelinesService = guidelinesService; + _environment = environment; + _searchService = serviceProvider.GetService(); + } + + [McpServerTool(Title = "Get Section Content", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Retrieve the prose content of a specific book section identified by its slug/key (e.g., 'hello-world', 'creating-editing-compiling-and-running-c-source-code'). Returns the section text with code examples preserved. Use GetChapterSections to discover available slugs.")] + public string GetSectionContent( + [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections to get valid slugs.")] string sectionKey, + [Description("Maximum number of characters to return (500–8000, default 4000). Long sections are truncated.")] int maxChars = 4000) + { + maxChars = Math.Clamp(maxChars, 500, 8000); + + SiteMapping? mapping = _siteMappingService.SiteMappings.Find(sectionKey); + if (mapping is null) + { + return $"Section '{sectionKey}' not found. Use GetChapterSections to discover valid section slugs."; + } + if (mapping.AnchorId is null) + { + return $"Section '{sectionKey}' does not have an anchor ID and cannot be extracted."; + } + + string filePath = Path.Join(_environment.ContentRootPath, Path.Join(mapping.PagePath)); + if (!File.Exists(filePath)) + { + return $"Chapter HTML file not found for section '{sectionKey}'. Content may not be generated yet."; + } + + HtmlDocument doc = new(); + doc.Load(filePath); + + var sectionNode = doc.DocumentNode.SelectSingleNode( + $"//div[@id='{mapping.AnchorId}' and contains(@class,'section-heading')]"); + + if (sectionNode is null) + { + return $"Section heading element not found for anchor '{mapping.AnchorId}'."; + } + + var parent = sectionNode.ParentNode; + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"## {mapping.RawHeading}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"Chapter {mapping.ChapterNumber}: {mapping.ChapterTitle}"); + sb.AppendLine(); + + bool collecting = false; + foreach (HtmlNode child in parent.ChildNodes) + { + if (!collecting) + { + if (child == sectionNode) collecting = true; + continue; + } + + // Stop at the next section heading div with an id attribute + if (child.Name == "div" && + child.HasAttributes && + !string.IsNullOrEmpty(child.GetAttributeValue("id", "")) && + child.GetAttributeValue("class", "").Contains("section-heading")) + { + break; + } + + ExtractNodeContent(child, sb); + + if (sb.Length >= maxChars) + { + sb.Append("\n\n[Content truncated — use a larger maxChars value to see more.]"); + break; + } + } + + return sb.Length == 0 ? $"No content found after section heading '{mapping.RawHeading}'." : sb.ToString(); + } + + [McpServerTool(Title = "Get Listing With Context", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Retrieve a specific book listing's source code together with the semantic book content that explains it. Combines code from GetListingSourceCode with related explanatory text found via search. Ideal for understanding what a listing demonstrates.")] + public async Task GetListingWithContext( + [Description("The chapter number of the listing.")] int chapter, + [Description("The listing number (e.g., 3 for Listing 5.3).")] int listing, + CancellationToken cancellationToken = default) + { + var response = await _listingService.GetListingAsync(chapter, listing); + if (response is null) + { + return $"Listing {chapter}.{listing} not found. Verify the chapter and listing numbers."; + } + + string langHint = NormalizeExtension(response.FileExtension); + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"## Listing {response.ChapterNumber}.{response.ListingNumber}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"```{langHint}"); + sb.AppendLine(response.Content); + sb.AppendLine("```"); + sb.AppendLine(); + + if (_searchService is not null) + { + string query = $"Chapter {chapter} listing {listing} {response.Content[..Math.Min(200, response.Content.Length)]}"; + var contextResults = await _searchService.ExecuteVectorSearch(query, cancellationToken: cancellationToken); + if (contextResults.Count > 0) + { + sb.AppendLine("### Related Book Explanations"); + sb.AppendLine(); + int count = 0; + foreach (var result in contextResults) + { + if (count++ >= 3) break; + if (!string.IsNullOrEmpty(result.Record.Heading)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"**{result.Record.Heading}** (Chapter {result.Record.ChapterNumber})"); + } + sb.AppendLine(result.Record.ChunkText); + sb.AppendLine(); + } + } + } + + return sb.ToString(); + } + + [McpServerTool(Title = "Get Navigation Context", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Get the navigation context for a book section: its breadcrumb path, the previous and next sections, its parent section, and its sibling sections. Useful for understanding where a section sits in the book's structure.")] + public string GetNavigationContext( + [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections to get valid slugs.")] string sectionKey) + { + SiteMapping? mapping = _siteMappingService.SiteMappings.Find(sectionKey); + if (mapping is null) + { + return $"Section '{sectionKey}' not found. Use GetChapterSections to discover valid section slugs."; + } + + var ordered = _siteMappingService.SiteMappings + .OrderBy(m => m.ChapterNumber) + .ThenBy(m => m.PageNumber) + .ThenBy(m => m.OrderOnPage) + .ToList(); + + int idx = ordered.IndexOf(mapping); + if (idx < 0) + { + return $"Section '{sectionKey}' could not be located in the ordered mapping list."; + } + + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"## Navigation Context: {mapping.RawHeading}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"Chapter {mapping.ChapterNumber}: {mapping.ChapterTitle} | Indent level: {mapping.IndentLevel}"); + sb.AppendLine(); + + // Breadcrumb: ancestors (same chapter, descending indent levels) + var breadcrumb = new List(); + int targetIndent = mapping.IndentLevel; + for (int i = idx - 1; i >= 0 && targetIndent > 0; i--) + { + if (ordered[i].ChapterNumber != mapping.ChapterNumber) break; + if (ordered[i].IndentLevel < targetIndent) + { + breadcrumb.Insert(0, ordered[i]); + targetIndent = ordered[i].IndentLevel; + } + } + if (breadcrumb.Count > 0) + { + sb.Append("**Breadcrumb:** "); + sb.AppendJoin(" > ", breadcrumb.Select(m => m.RawHeading)); + sb.AppendLine(CultureInfo.InvariantCulture, $" > {mapping.RawHeading}"); + sb.AppendLine(); + } + + // Parent: nearest preceding mapping in same chapter with indent level - 1 + SiteMapping? parent = null; + if (mapping.IndentLevel > 0) + { + for (int i = idx - 1; i >= 0; i--) + { + if (ordered[i].ChapterNumber != mapping.ChapterNumber) break; + if (ordered[i].IndentLevel == mapping.IndentLevel - 1) + { + parent = ordered[i]; + break; + } + } + } + if (parent is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"**Parent:** {parent.RawHeading} (`/{parent.Keys.First()}#{parent.AnchorId}`)"); + sb.AppendLine(); + } + + // Previous section at same indent level in the same chapter + SiteMapping? prev = null; + for (int i = idx - 1; i >= 0; i--) + { + if (ordered[i].ChapterNumber != mapping.ChapterNumber) break; + if (ordered[i].IndentLevel == mapping.IndentLevel) + { + prev = ordered[i]; + break; + } + } + if (prev is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"**Previous:** {prev.RawHeading} (`/{prev.Keys.First()}#{prev.AnchorId}`)"); + } + + // Next section at same indent level in the same chapter + SiteMapping? next = null; + for (int i = idx + 1; i < ordered.Count; i++) + { + if (ordered[i].ChapterNumber != mapping.ChapterNumber) break; + if (ordered[i].IndentLevel < mapping.IndentLevel) break; + if (ordered[i].IndentLevel == mapping.IndentLevel) + { + next = ordered[i]; + break; + } + } + if (next is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"**Next:** {next.RawHeading} (`/{next.Keys.First()}#{next.AnchorId}`)"); + } + + // Siblings: all siblings sharing the same parent + if (parent is not null) + { + int parentIdx = ordered.IndexOf(parent); + var siblings = new List(); + for (int i = parentIdx + 1; i < ordered.Count; i++) + { + if (ordered[i].ChapterNumber != mapping.ChapterNumber) break; + if (ordered[i].IndentLevel < mapping.IndentLevel) break; + if (ordered[i].IndentLevel == mapping.IndentLevel && ordered[i] != mapping) + { + siblings.Add(ordered[i]); + } + } + if (siblings.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("**Sibling sections:**"); + foreach (var s in siblings) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" - {s.RawHeading} (`/{s.Keys.First()}#{s.AnchorId}`)"); + } + } + } + + return sb.ToString(); + } + + [McpServerTool(Title = "Get Chapter Summary", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Get a structural overview of a book chapter: its top-level section headings in reading order, and the coding guidelines associated with that chapter. Useful for understanding what a chapter covers before diving in.")] + public string GetChapterSummary( + [Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) + { + var chapterMappings = _siteMappingService.SiteMappings + .Where(m => m.ChapterNumber == chapter) + .OrderBy(m => m.PageNumber) + .ThenBy(m => m.OrderOnPage) + .ToList(); + + if (chapterMappings.Count == 0) + { + return $"Chapter {chapter} not found in the book's table of contents."; + } + + string chapterTitle = chapterMappings.First().ChapterTitle; + + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Chapter {chapter}: {chapterTitle}"); + sb.AppendLine(); + sb.AppendLine("## Sections"); + + foreach (var m in chapterMappings.Where(m => m.IndentLevel <= 1)) + { + string indent = m.IndentLevel == 0 ? "" : " "; + string link = $"`/{m.Keys.First()}#{m.AnchorId}`"; + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}- {m.RawHeading} ({link})"); + } + + var guidelines = _guidelinesService.Guidelines + .Where(g => g.ChapterNumber == chapter) + .ToList(); + + if (guidelines.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("## Guidelines in this Chapter"); + foreach (var g in guidelines) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"- **[{FormatGuidelineType(g.Type)}]** {g.Guideline}"); + } + } + + return sb.ToString(); + } + + private static void ExtractNodeContent(HtmlNode node, StringBuilder sb) + { + if (node.NodeType == HtmlNodeType.Text) + { + string text = HtmlEntity.DeEntitize(node.InnerText).Trim(); + if (!string.IsNullOrEmpty(text)) + { + sb.AppendLine(text); + } + return; + } + + if (node.Name is not ("div" or "p" or "ul" or "ol" or "li" or "span")) return; + + string nodeClass = node.GetAttributeValue("class", ""); + + // Code block: extract heading + code lines + if (nodeClass.Contains("code-block-section")) + { + var headingNode = node.SelectSingleNode(".//div[contains(@class,'code-block-heading')]"); + if (headingNode is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"\n**{HtmlEntity.DeEntitize(headingNode.InnerText).Trim()}**"); + } + + sb.AppendLine("```csharp"); + var codeLines = node.SelectNodes(".//div[contains(@class,'code-line')]"); + if (codeLines is not null) + { + foreach (var line in codeLines) + { + // Remove the line-number span before extracting text + var lineNumberSpan = line.SelectSingleNode(".//span[contains(@class,'code-line-number')]"); + lineNumberSpan?.Remove(); + sb.AppendLine(HtmlEntity.DeEntitize(line.InnerText)); + } + } + sb.AppendLine("```"); + return; + } + + // Paragraphs and other content + if (node.Name is "p" || nodeClass.Contains("paragraph")) + { + string text = HtmlEntity.DeEntitize(node.InnerText).Trim(); + if (!string.IsNullOrEmpty(text)) + { + sb.AppendLine(text); + sb.AppendLine(); + } + return; + } + + // Recurse into other divs (skill-topic-block, etc.) + foreach (HtmlNode child in node.ChildNodes) + { + ExtractNodeContent(child, sb); + } + } + + private static string NormalizeExtension(string ext) => + ext.TrimStart('.').ToLowerInvariant() switch + { + "cs" => "csharp", + "vb" => "vbnet", + "fs" => "fsharp", + "" => "", + var e => e + }; + + private static string FormatGuidelineType(GuidelineType type) => type switch + { + GuidelineType.Do => "DO", + GuidelineType.Consider => "CONSIDER", + GuidelineType.Avoid => "AVOID", + GuidelineType.DoNot => "DO NOT", + _ => "NOTE" + }; +} diff --git a/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs new file mode 100644 index 00000000..4f26306e --- /dev/null +++ b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs @@ -0,0 +1,133 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text; +using EssentialCSharp.Web.Services; +using ModelContextProtocol.Server; + +namespace EssentialCSharp.Web.Tools; + +[McpServerToolType] +public sealed class BookGuidelinesTool +{ + private readonly IGuidelinesService _guidelinesService; + + public BookGuidelinesTool(IGuidelinesService guidelinesService) + { + _guidelinesService = guidelinesService; + } + + [McpServerTool(Title = "Get C# Guidelines", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Retrieve C# coding guidelines from the Essential C# book. Optionally filter by keyword, chapter number, or guideline type (do/consider/avoid/donot). The book contains guidelines covering naming conventions, error handling, LINQ, async/await, generics, and many other topics. Each guideline includes its chapter and subsection context.")] + public string GetCSharpGuidelines( + [Description("Optional keyword to filter guidelines by (searched in guideline text and subsection name).")] string? keyword = null, + [Description("Optional chapter number to restrict results to a specific chapter.")] int? chapter = null, + [Description("Optional guideline type: 'do', 'consider', 'avoid', or 'donot' (also accepts 'do not', 'dont').")] string? type = null, + [Description("Maximum number of guidelines to return (1–50, default 20).")] int maxResults = 20) + { + maxResults = Math.Clamp(maxResults, 1, 50); + GuidelineType? typeFilter = ParseGuidelineType(type); + + IEnumerable filtered = _guidelinesService.Guidelines; + + if (chapter.HasValue) + filtered = filtered.Where(g => g.ChapterNumber == chapter.Value); + + if (typeFilter.HasValue) + filtered = filtered.Where(g => g.Type == typeFilter.Value); + + if (!string.IsNullOrWhiteSpace(keyword)) + filtered = filtered.Where(g => + g.Guideline.Contains(keyword, StringComparison.OrdinalIgnoreCase) || + g.SanitizedSubsection.Contains(keyword, StringComparison.OrdinalIgnoreCase) || + (g.ActualSubsection?.Contains(keyword, StringComparison.OrdinalIgnoreCase) == true)); + + var results = filtered.Take(maxResults).ToList(); + + if (results.Count == 0) + { + return "No guidelines found matching the specified filters."; + } + + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Essential C# Guidelines ({results.Count} result{(results.Count == 1 ? "" : "s")})"); + sb.AppendLine(); + + foreach (var g in results) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"**[{FormatType(g.Type)}]** {g.Guideline}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" — Chapter {g.ChapterNumber}: {g.ChapterTitle} / {g.SanitizedSubsection}"); + sb.AppendLine(); + } + + return sb.ToString(); + } + + [McpServerTool(Title = "Get Guidelines By Topic", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Search C# coding guidelines from the Essential C# book by topic or concept. More discoverable than filtering by chapter — finds all guidelines related to exceptions, naming, async, LINQ, generics, interfaces, and more. Results are ordered by relevance to the topic.")] + public string GetGuidelinesByTopic( + [Description("The topic or concept to search guidelines for (e.g., 'exception handling', 'naming', 'async', 'LINQ', 'generics', 'interface').")] string topic, + [Description("Maximum number of guidelines to return (1–30, default 15).")] int maxResults = 15) + { + if (string.IsNullOrWhiteSpace(topic)) + { + return "Topic must not be empty."; + } + + maxResults = Math.Clamp(maxResults, 1, 30); + + var words = topic.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var scored = _guidelinesService.Guidelines + .Select(g => + { + string combined = $"{g.Guideline} {g.SanitizedSubsection} {g.ActualSubsection} {g.ChapterTitle}"; + int score = words.Count(w => combined.Contains(w, StringComparison.OrdinalIgnoreCase)); + return (guideline: g, score); + }) + .Where(x => x.score > 0) + .OrderByDescending(x => x.score) + .Take(maxResults) + .ToList(); + + if (scored.Count == 0) + { + return $"No guidelines found related to '{topic}'."; + } + + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Essential C# Guidelines — Topic: {topic} ({scored.Count} result{(scored.Count == 1 ? "" : "s")})"); + sb.AppendLine(); + + foreach (var (g, _) in scored) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"**[{FormatType(g.Type)}]** {g.Guideline}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" — Chapter {g.ChapterNumber}: {g.ChapterTitle} / {g.SanitizedSubsection}"); + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static GuidelineType? ParseGuidelineType(string? input) + { + if (string.IsNullOrWhiteSpace(input)) return null; + + return input.Trim().ToLowerInvariant().Replace(" ", "").Replace("_", "") switch + { + "do" => GuidelineType.Do, + "consider" => GuidelineType.Consider, + "avoid" => GuidelineType.Avoid, + "donot" or "dont" or "donotdo" => GuidelineType.DoNot, + _ => null + }; + } + + private static string FormatType(GuidelineType type) => type switch + { + GuidelineType.Do => "DO", + GuidelineType.Consider => "CONSIDER", + GuidelineType.Avoid => "AVOID", + GuidelineType.DoNot => "DO NOT", + _ => "NOTE" + }; +} diff --git a/EssentialCSharp.Web/Tools/BookListingTool.cs b/EssentialCSharp.Web/Tools/BookListingTool.cs new file mode 100644 index 00000000..a3ead64e --- /dev/null +++ b/EssentialCSharp.Web/Tools/BookListingTool.cs @@ -0,0 +1,100 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text; +using EssentialCSharp.Web.Services; +using ModelContextProtocol.Server; + +namespace EssentialCSharp.Web.Tools; + +[McpServerToolType] +public sealed class BookListingTool +{ + private readonly IListingSourceCodeService _listingService; + private readonly ISiteMappingService _siteMappingService; + + public BookListingTool(IListingSourceCodeService listingService, ISiteMappingService siteMappingService) + { + _listingService = listingService; + _siteMappingService = siteMappingService; + } + + [McpServerTool(Title = "Get Listing Source Code", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Retrieve the complete source code for a specific numbered listing from the Essential C# book. Example: chapter=5, listing=3 retrieves Listing 5.3. Returns the code and its file type.")] + public async Task GetListingSourceCode( + [Description("The chapter number containing the listing (e.g., 5 for Chapter 5).")] int chapter, + [Description("The listing number within the chapter (e.g., 3 for Listing 5.3).")] int listing, + CancellationToken cancellationToken = default) + { + var response = await _listingService.GetListingAsync(chapter, listing); + if (response is null) + { + return $"Listing {chapter}.{listing} not found. Verify that both the chapter and listing numbers are correct."; + } + + string langHint = NormalizeExtension(response.FileExtension); + return $"## Listing {response.ChapterNumber}.{response.ListingNumber}\n\n```{langHint}\n{response.Content}\n```"; + } + + [McpServerTool(Title = "Search Listings By Code", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Search all code listings in the Essential C# book for a specific code pattern, keyword, or identifier. Searches actual C# source code (not prose). Useful for finding examples of Task.WhenAll, yield return, IDisposable, pattern matching, and similar code constructs.")] + public async Task SearchListingsByCode( + [Description("The code pattern or keyword to search for in listing source code (case-insensitive substring match).")] string pattern, + [Description("Maximum number of matching listings to return (1–20, default 10).")] int maxResults = 10, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(pattern)) + { + return "Pattern must not be empty."; + } + + maxResults = Math.Clamp(maxResults, 1, 20); + + var distinctChapters = _siteMappingService.SiteMappings + .Select(m => m.ChapterNumber) + .Distinct() + .OrderBy(n => n); + + var sb = new StringBuilder(); + int found = 0; + + foreach (int chapterNumber in distinctChapters) + { + if (found >= maxResults) break; + cancellationToken.ThrowIfCancellationRequested(); + + var listings = await _listingService.GetListingsByChapterAsync(chapterNumber); + foreach (var listing in listings) + { + if (found >= maxResults) break; + if (listing.Content.Contains(pattern, StringComparison.OrdinalIgnoreCase)) + { + string langHint = NormalizeExtension(listing.FileExtension); + sb.AppendLine(CultureInfo.InvariantCulture, $"### Listing {listing.ChapterNumber}.{listing.ListingNumber}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"```{langHint}"); + sb.AppendLine(listing.Content); + sb.AppendLine("```"); + sb.AppendLine(); + found++; + } + } + } + + if (found == 0) + { + return $"No listings found containing '{pattern}'."; + } + + sb.Insert(0, $"# Listings Containing '{pattern}' ({found} result{(found == 1 ? "" : "s")})\n\n"); + return sb.ToString(); + } + + private static string NormalizeExtension(string ext) => + ext.TrimStart('.').ToLowerInvariant() switch + { + "cs" => "csharp", + "vb" => "vbnet", + "fs" => "fsharp", + "" => "", + var e => e + }; +} diff --git a/EssentialCSharp.Web/Tools/BookSearchTool.cs b/EssentialCSharp.Web/Tools/BookSearchTool.cs index af3ee3c4..a0a98d0c 100644 --- a/EssentialCSharp.Web/Tools/BookSearchTool.cs +++ b/EssentialCSharp.Web/Tools/BookSearchTool.cs @@ -1,12 +1,26 @@ using System.ComponentModel; using System.Globalization; using System.Text; +using System.Text.RegularExpressions; using EssentialCSharp.Chat.Common.Services; +using EssentialCSharp.Web.Extensions; using EssentialCSharp.Web.Services; using ModelContextProtocol.Server; namespace EssentialCSharp.Web.Tools; +// Book metadata constants — update here when edition changes. +file static class BookMetadata +{ + public const string Title = "Essential C#"; + public const string Edition = "9th Edition"; + public const string CSharpVersion = "C# 13.0"; + public const string Authors = "Mark and Benjamin Michaelis"; + public const string Publisher = "Addison-Wesley Professional"; + public const string Isbn13 = "978-0-13-576056-5"; + public const string SiteUrl = "https://essentialcsharp.com"; +} + [McpServerToolType] public sealed class BookSearchTool { @@ -92,4 +106,383 @@ public string GetChapterList() return sb.ToString(); } + + [McpServerTool(Title = "Get Chapter Sections", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Get all sections and subsections in a specific chapter of the Essential C# book, in reading order. Returns each section's heading, slug, anchor link, and indent level. Use the returned slugs with other tools like GetSectionContent or GetNavigationContext.")] + public string GetChapterSections( + [Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) + { + var sections = _SiteMappingService.SiteMappings + .Where(m => m.ChapterNumber == chapter) + .OrderBy(m => m.PageNumber) + .ThenBy(m => m.OrderOnPage) + .ToList(); + + if (sections.Count == 0) + { + return $"Chapter {chapter} not found. Use GetChapterList to see all available chapters."; + } + + string chapterTitle = sections.First().ChapterTitle; + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Chapter {chapter}: {chapterTitle} — Sections"); + sb.AppendLine(); + + foreach (var m in sections) + { + string indent = new(' ', m.IndentLevel * 2); + string slug = m.Keys.FirstOrDefault() ?? m.PrimaryKey; + string anchor = m.AnchorId is not null ? $"#{m.AnchorId}" : ""; + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}- {m.RawHeading} (slug: `{slug}`, link: `/{slug}{anchor}`)"); + } + + return sb.ToString(); + } + + [McpServerTool(Title = "Get Direct Content URL", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Get the canonical deep-link URL for a specific book section or subsection. Returns a clickable URL that navigates directly to the section. Use this to include precise references in responses.")] + public string GetDirectContentUrl( + [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections or GetChapterList to find valid slugs.")] string sectionKey) + { + SiteMapping? mapping = _SiteMappingService.SiteMappings.Find(sectionKey); + if (mapping is null) + { + return $"Section '{sectionKey}' not found. Use GetChapterSections or GetChapterList to find valid section slugs."; + } + + string slug = mapping.Keys.FirstOrDefault() ?? mapping.PrimaryKey; + string anchor = mapping.AnchorId is not null ? $"#{mapping.AnchorId}" : ""; + string url = $"{BookMetadata.SiteUrl}/{slug}{anchor}"; + + return $"**{mapping.RawHeading}**\n" + + $"Chapter {mapping.ChapterNumber}: {mapping.ChapterTitle}\n" + + $"URL: {url}"; + } + + [McpServerTool(Title = "Get Book Metadata", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Get citation-quality metadata for the Essential C# book: title, authors, edition, C# version covered, ISBN, publisher, and website URL. Use this when generating citations or when asked which edition or C# version the book covers.")] + public string GetBookMetadata() + { + return $""" + # {BookMetadata.Title} — Book Metadata + + **Title:** {BookMetadata.Title} + **Edition:** {BookMetadata.Edition} + **C# Version:** {BookMetadata.CSharpVersion} + **Authors:** {BookMetadata.Authors} + **Publisher:** {BookMetadata.Publisher} + **ISBN-13:** {BookMetadata.Isbn13} + **Website:** {BookMetadata.SiteUrl} + """; + } + + [McpServerTool(Title = "Lookup Concept", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Find all sections in the Essential C# book that cover a specific C# concept. Combines section heading search with semantic vector search (when available) to give broad coverage. Returns section slugs, chapter numbers, and direct links.")] + public async Task LookupConcept( + [Description("The C# concept, feature, or topic to find in the book (e.g., 'LINQ', 'async/await', 'pattern matching', 'generics').")] string concept, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(concept)) + { + return "Concept must not be empty."; + } + + // Heading / key text search + var headingMatches = _SiteMappingService.SiteMappings + .Where(m => + m.RawHeading.Contains(concept, StringComparison.OrdinalIgnoreCase) || + m.Keys.Any(k => k.Contains(concept.Replace(' ', '-'), StringComparison.OrdinalIgnoreCase))) + .OrderBy(m => m.ChapterNumber) + .ThenBy(m => m.PageNumber) + .ThenBy(m => m.OrderOnPage) + .ToList(); + + // Vector search results + var vectorMatches = new List<(int chapter, string heading, string chunkText)>(); + if (_SearchService is not null) + { + var results = await _SearchService.ExecuteVectorSearch(concept, cancellationToken: cancellationToken); + foreach (var r in results) + { + vectorMatches.Add((r.Record.ChapterNumber ?? 0, r.Record.Heading ?? "", r.Record.ChunkText)); + } + } + + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Book Coverage: '{concept}'"); + sb.AppendLine(); + + if (headingMatches.Count > 0) + { + sb.AppendLine("## Sections with matching headings"); + foreach (var m in headingMatches) + { + string slug = m.Keys.FirstOrDefault() ?? m.PrimaryKey; + string anchor = m.AnchorId is not null ? $"#{m.AnchorId}" : ""; + sb.AppendLine(CultureInfo.InvariantCulture, + $"- **{m.RawHeading}** (Ch. {m.ChapterNumber}) — `/{slug}{anchor}`"); + } + sb.AppendLine(); + } + + if (vectorMatches.Count > 0) + { + sb.AppendLine("## Related content (semantic search)"); + // Deduplicate by heading + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (ch, heading, text) in vectorMatches) + { + if (!seen.Add(heading)) continue; + sb.AppendLine(CultureInfo.InvariantCulture, $"- **{heading}** (Ch. {ch})"); + sb.AppendLine(CultureInfo.InvariantCulture, $" > {text[..Math.Min(200, text.Length)]}..."); + } + sb.AppendLine(); + } + + if (headingMatches.Count == 0 && vectorMatches.Count == 0) + { + return $"No book content found for '{concept}'. Try a different term or check the table of contents with GetChapterList."; + } + + return sb.ToString(); + } + + [McpServerTool(Title = "Check Topic Coverage", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Determine whether and how thoroughly the Essential C# book covers a given topic. Returns a coverage assessment: 'Comprehensive', 'Mentioned', or 'Not found in headings'. Use this before citing the book to calibrate confidence.")] + public async Task CheckTopicCoverage( + [Description("The C# topic, feature, or concept to check (e.g., 'source generators', 'records', 'LINQ').")] string topic, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(topic)) + { + return "Topic must not be empty."; + } + + // Heading search + var headingMatches = _SiteMappingService.SiteMappings + .Where(m => + m.RawHeading.Contains(topic, StringComparison.OrdinalIgnoreCase) || + m.Keys.Any(k => k.Contains(topic.Replace(' ', '-'), StringComparison.OrdinalIgnoreCase))) + .ToList(); + + bool hasHeadingCoverage = headingMatches.Count > 0; + bool hasSemanticCoverage = false; + bool semanticAvailable = _SearchService is not null; + + if (semanticAvailable) + { + var results = await _SearchService!.ExecuteVectorSearch(topic, cancellationToken: cancellationToken); + hasSemanticCoverage = results.Count > 0; + } + + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Topic Coverage: '{topic}'"); + sb.AppendLine(); + + string assessment; + if (hasHeadingCoverage && (hasSemanticCoverage || !semanticAvailable)) + { + assessment = "**Comprehensive** — dedicated section headings found"; + } + else if (hasHeadingCoverage) + { + assessment = "**Mentioned** — appears in section headings"; + } + else if (hasSemanticCoverage) + { + assessment = "**Mentioned** — referenced in book content but no dedicated heading"; + } + else if (!semanticAvailable) + { + assessment = hasHeadingCoverage + ? "**Mentioned** — appears in section headings (semantic search unavailable for deeper analysis)" + : "**Not found in headings** — semantic search unavailable; topic may still be discussed in prose"; + } + else + { + assessment = "**Not covered** — not found in section headings or semantic search"; + } + + sb.AppendLine(CultureInfo.InvariantCulture, $"**Assessment:** {assessment}"); + + if (headingMatches.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("**Relevant sections:**"); + foreach (var m in headingMatches.Take(5)) + { + string slug = m.Keys.FirstOrDefault() ?? m.PrimaryKey; + sb.AppendLine(CultureInfo.InvariantCulture, $" - {m.RawHeading} (Ch. {m.ChapterNumber}) — `/{slug}#{m.AnchorId}`"); + } + } + + return sb.ToString(); + } + + [McpServerTool(Title = "Find Book Help For Diagnostic", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Find Essential C# book sections and guidelines that help explain a C# compiler error, warning, or runtime exception. Accepts a CS diagnostic code (e.g., 'CS8600') or a plain description (e.g., 'null reference exception', 'cannot implicitly convert'). Returns relevant book sections and guidelines.")] + public async Task FindBookHelpForDiagnostic( + [Description("A C# compiler diagnostic code (e.g., 'CS8600', 'CS0029') or a plain error description (e.g., 'null reference exception', 'async method lacks await').")] string diagnostic, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(diagnostic)) + { + return "Diagnostic must not be empty."; + } + + // Map well-known CS codes to relevant search topics + string searchTerm = MapDiagnosticToTopic(diagnostic); + + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Book Help for: {diagnostic}"); + if (!string.Equals(searchTerm, diagnostic, StringComparison.OrdinalIgnoreCase)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"Searching for: '{searchTerm}'"); + } + sb.AppendLine(); + + // Heading search on mapped topic + var headingMatches = _SiteMappingService.SiteMappings + .Where(m => m.RawHeading.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) + .Take(5) + .ToList(); + + if (headingMatches.Count > 0) + { + sb.AppendLine("## Relevant Book Sections"); + foreach (var m in headingMatches) + { + string slug = m.Keys.FirstOrDefault() ?? m.PrimaryKey; + sb.AppendLine(CultureInfo.InvariantCulture, $"- **{m.RawHeading}** (Ch. {m.ChapterNumber}) — `/{slug}#{m.AnchorId}`"); + } + sb.AppendLine(); + } + + // Vector search + if (_SearchService is not null) + { + var vectorResults = await _SearchService.ExecuteVectorSearch(searchTerm, cancellationToken: cancellationToken); + if (vectorResults.Count > 0) + { + sb.AppendLine("## Relevant Book Content"); + int count = 0; + foreach (var r in vectorResults) + { + if (count++ >= 3) break; + if (!string.IsNullOrEmpty(r.Record.Heading)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"**{r.Record.Heading}** (Ch. {r.Record.ChapterNumber})"); + } + sb.AppendLine(r.Record.ChunkText); + sb.AppendLine(); + } + } + } + + if (headingMatches.Count == 0 && _SearchService is null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"No sections found for '{searchTerm}'. Semantic search is unavailable in this environment."); + } + + return sb.ToString(); + } + + [McpServerTool(Title = "Find Related Sections", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + Description("Find other sections in the Essential C# book that are semantically related to a given section. Uses the section heading as a search query to discover thematically connected content across the entire book. Requires AI services to be configured.")] + public async Task FindRelatedSections( + [Description("The section slug/key to find related content for (e.g., 'async-await'). Use GetChapterSections to get valid slugs.")] string sectionKey, + CancellationToken cancellationToken = default) + { + SiteMapping? mapping = _SiteMappingService.SiteMappings.Find(sectionKey); + if (mapping is null) + { + return $"Section '{sectionKey}' not found. Use GetChapterSections to discover valid section slugs."; + } + + if (_SearchService is null) + { + return "FindRelatedSections requires AI services, which are not configured in this environment. Use LookupConcept for heading-based search."; + } + + string query = $"{mapping.RawHeading} {mapping.ChapterTitle}"; + var results = await _SearchService.ExecuteVectorSearch(query, cancellationToken: cancellationToken); + + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Sections Related to: {mapping.RawHeading}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"(Chapter {mapping.ChapterNumber}: {mapping.ChapterTitle})"); + sb.AppendLine(); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { mapping.RawHeading }; + int count = 0; + foreach (var r in results) + { + string heading = r.Record.Heading ?? ""; + if (!seen.Add(heading)) continue; + if (count++ >= 5) break; + + // Find the SiteMapping for this heading to get the link + SiteMapping? relatedMapping = _SiteMappingService.SiteMappings + .FirstOrDefault(m => m.RawHeading.Equals(heading, StringComparison.OrdinalIgnoreCase) + && m.ChapterNumber == (r.Record.ChapterNumber ?? 0)); + + string link = relatedMapping is not null + ? $"`/{relatedMapping.Keys.FirstOrDefault() ?? relatedMapping.PrimaryKey}#{relatedMapping.AnchorId}`" + : $"Ch. {r.Record.ChapterNumber}"; + + sb.AppendLine(CultureInfo.InvariantCulture, $"- **{heading}** ({link})"); + sb.AppendLine(CultureInfo.InvariantCulture, $" > {r.Record.ChunkText[..Math.Min(200, r.Record.ChunkText.Length)]}..."); + sb.AppendLine(); + } + + if (count == 0) + { + sb.AppendLine("No related sections found."); + } + + return sb.ToString(); + } + + private static readonly Dictionary DiagnosticMap = new(StringComparer.OrdinalIgnoreCase) + { + // Nullable reference types + ["CS8600"] = "nullable reference types", + ["CS8601"] = "nullable reference types", + ["CS8602"] = "nullable reference types dereference", + ["CS8603"] = "nullable reference types", + ["CS8604"] = "nullable reference types", + ["CS8618"] = "nullable reference types constructor", + ["CS8625"] = "nullable reference types null literal", + // Type conversions + ["CS0029"] = "implicit type conversion", + ["CS0030"] = "explicit type casting", + // Async + ["CS1998"] = "async await", + ["CS4014"] = "async await task", + // Access modifiers + ["CS0122"] = "access modifiers", + // Missing members + ["CS0103"] = "variable declaration scope", + ["CS0246"] = "using directives namespaces", + // Interface implementation + ["CS0535"] = "interface implementation", + ["CS0738"] = "interface implementation", + // Override + ["CS0115"] = "virtual override polymorphism", + // Generics + ["CS0314"] = "generics constraints", + ["CS0453"] = "generics value types", + }; + + private static string MapDiagnosticToTopic(string diagnostic) + { + // Try exact CS code match first + var codeMatch = Regex.Match(diagnostic, @"CS\d+", RegexOptions.IgnoreCase); + if (codeMatch.Success && DiagnosticMap.TryGetValue(codeMatch.Value.ToUpperInvariant(), out string? mapped)) + { + return mapped; + } + + // Fall back to the raw description for vector search + return diagnostic; + } } From 9c6c59771da25a5e4a40aeaca1d96f3923f2d405 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 24 Apr 2026 22:59:15 -0700 Subject: [PATCH 05/14] fix: apply multi-agent code review findings to MCP tools - Remove Destructive = false from all 17 MCP tool annotations (default per spec) - Fix FindBookHelpForDiagnostic description to accurately reflect guidelines output - Add 500-char max length validation to SearchBookContent, LookupConcept, CheckTopicCoverage, FindBookHelpForDiagnostic - Add missing empty check to SearchBookContent - Make GetSectionContent async (File.ReadAllTextAsync + doc.LoadHtml, removes TOCTOU File.Exists) - Add path canonicalization via Path.GetRelativePath (robust cross-platform, handles drive-root edge case) - Add .html file extension allowlist check on resolved path - Add AnchorId validation via [GeneratedRegex] before XPath interpolation - Separate IOException catches: FileNotFoundException/DirectoryNotFoundException show 'not generated yet', UnauthorizedAccessException and IOException show generic messages (no ex.Message leak) - Make BookContentTool a partial class for GeneratedRegex support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- EssentialCSharp.Web/Tools/BookContentTool.cs | 121 ++++++++----- .../Tools/BookGuidelinesTool.cs | 11 +- EssentialCSharp.Web/Tools/BookListingTool.cs | 18 +- EssentialCSharp.Web/Tools/BookSearchTool.cs | 160 ++++++++++++------ EssentialCSharp.Web/Tools/BookToolHelpers.cs | 14 ++ 5 files changed, 219 insertions(+), 105 deletions(-) create mode 100644 EssentialCSharp.Web/Tools/BookToolHelpers.cs diff --git a/EssentialCSharp.Web/Tools/BookContentTool.cs b/EssentialCSharp.Web/Tools/BookContentTool.cs index bd04420d..24c7d40f 100644 --- a/EssentialCSharp.Web/Tools/BookContentTool.cs +++ b/EssentialCSharp.Web/Tools/BookContentTool.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Globalization; using System.Text; +using System.Text.RegularExpressions; using EssentialCSharp.Chat.Common.Services; using EssentialCSharp.Web.Extensions; using EssentialCSharp.Web.Services; @@ -10,7 +11,7 @@ namespace EssentialCSharp.Web.Tools; [McpServerToolType] -public sealed class BookContentTool +public sealed partial class BookContentTool { private readonly ISiteMappingService _siteMappingService; private readonly IListingSourceCodeService _listingService; @@ -32,32 +33,72 @@ public BookContentTool( _searchService = serviceProvider.GetService(); } - [McpServerTool(Title = "Get Section Content", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Section Content", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Retrieve the prose content of a specific book section identified by its slug/key (e.g., 'hello-world', 'creating-editing-compiling-and-running-c-source-code'). Returns the section text with code examples preserved. Use GetChapterSections to discover available slugs.")] - public string GetSectionContent( + public async Task GetSectionContent( [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections to get valid slugs.")] string sectionKey, - [Description("Maximum number of characters to return (500–8000, default 4000). Long sections are truncated.")] int maxChars = 4000) + [Description("Maximum number of characters to return (500–8000, default 4000). Long sections are truncated.")] int maxChars = 4000, + CancellationToken cancellationToken = default) { maxChars = Math.Clamp(maxChars, 500, 8000); + if (string.IsNullOrWhiteSpace(sectionKey)) + { + return "Section key must not be empty. Use GetChapterSections to discover valid section slugs."; + } + SiteMapping? mapping = _siteMappingService.SiteMappings.Find(sectionKey); if (mapping is null) { return $"Section '{sectionKey}' not found. Use GetChapterSections to discover valid section slugs."; } - if (mapping.AnchorId is null) + if (mapping.AnchorId is null || string.IsNullOrWhiteSpace(mapping.AnchorId)) { return $"Section '{sectionKey}' does not have an anchor ID and cannot be extracted."; } + if (!AnchorIdRegex().IsMatch(mapping.AnchorId)) + { + return $"Section '{sectionKey}' has an invalid anchor ID."; + } - string filePath = Path.Join(_environment.ContentRootPath, Path.Join(mapping.PagePath)); - if (!File.Exists(filePath)) + string contentRoot = Path.GetFullPath(_environment.ContentRootPath); + string filePath = Path.GetFullPath(Path.Join(contentRoot, Path.Join(mapping.PagePath))); + string relative = Path.GetRelativePath(contentRoot, filePath); + if (relative == ".." || + relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || + Path.IsPathRooted(relative)) + { + return $"Section '{sectionKey}' has an invalid path."; + } + if (!string.Equals(Path.GetExtension(filePath), ".html", StringComparison.OrdinalIgnoreCase)) + { + return $"Section '{sectionKey}' has an invalid path."; + } + + string htmlContent; + try + { + htmlContent = await File.ReadAllTextAsync(filePath, cancellationToken); + } + catch (FileNotFoundException) { return $"Chapter HTML file not found for section '{sectionKey}'. Content may not be generated yet."; } + catch (DirectoryNotFoundException) + { + return $"Chapter HTML file not found for section '{sectionKey}'. Content may not be generated yet."; + } + catch (UnauthorizedAccessException) + { + return $"Chapter HTML could not be accessed for section '{sectionKey}'."; + } + catch (IOException) + { + return $"Failed to read chapter HTML for section '{sectionKey}'."; + } HtmlDocument doc = new(); - doc.Load(filePath); + doc.LoadHtml(htmlContent); var sectionNode = doc.DocumentNode.SelectSingleNode( $"//div[@id='{mapping.AnchorId}' and contains(@class,'section-heading')]"); @@ -68,11 +109,12 @@ public string GetSectionContent( } var parent = sectionNode.ParentNode; - var sb = new StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"## {mapping.RawHeading}"); - sb.AppendLine(CultureInfo.InvariantCulture, $"Chapter {mapping.ChapterNumber}: {mapping.ChapterTitle}"); - sb.AppendLine(); + var header = new StringBuilder(); + header.AppendLine(CultureInfo.InvariantCulture, $"## {mapping.RawHeading}"); + header.AppendLine(CultureInfo.InvariantCulture, $"Chapter {mapping.ChapterNumber}: {mapping.ChapterTitle}"); + header.AppendLine(); + var body = new StringBuilder(); bool collecting = false; foreach (HtmlNode child in parent.ChildNodes) { @@ -91,19 +133,19 @@ public string GetSectionContent( break; } - ExtractNodeContent(child, sb); + ExtractNodeContent(child, body); - if (sb.Length >= maxChars) + if (body.Length >= maxChars) { - sb.Append("\n\n[Content truncated — use a larger maxChars value to see more.]"); + body.Append("\n\n[Content truncated — use a larger maxChars value to see more.]"); break; } } - return sb.Length == 0 ? $"No content found after section heading '{mapping.RawHeading}'." : sb.ToString(); + return body.Length == 0 ? $"No content found after section heading '{mapping.RawHeading}'." : header.Append(body).ToString(); } - [McpServerTool(Title = "Get Listing With Context", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Listing With Context", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Retrieve a specific book listing's source code together with the semantic book content that explains it. Combines code from GetListingSourceCode with related explanatory text found via search. Ideal for understanding what a listing demonstrates.")] public async Task GetListingWithContext( [Description("The chapter number of the listing.")] int chapter, @@ -116,7 +158,7 @@ public async Task GetListingWithContext( return $"Listing {chapter}.{listing} not found. Verify the chapter and listing numbers."; } - string langHint = NormalizeExtension(response.FileExtension); + string langHint = BookToolHelpers.NormalizeExtension(response.FileExtension); var sb = new StringBuilder(); sb.AppendLine(CultureInfo.InvariantCulture, $"## Listing {response.ChapterNumber}.{response.ListingNumber}"); sb.AppendLine(CultureInfo.InvariantCulture, $"```{langHint}"); @@ -149,11 +191,16 @@ public async Task GetListingWithContext( return sb.ToString(); } - [McpServerTool(Title = "Get Navigation Context", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Navigation Context", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Get the navigation context for a book section: its breadcrumb path, the previous and next sections, its parent section, and its sibling sections. Useful for understanding where a section sits in the book's structure.")] public string GetNavigationContext( [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections to get valid slugs.")] string sectionKey) { + if (string.IsNullOrWhiteSpace(sectionKey)) + { + return "Section key must not be empty. Use GetChapterSections to discover valid section slugs."; + } + SiteMapping? mapping = _siteMappingService.SiteMappings.Find(sectionKey); if (mapping is null) { @@ -166,7 +213,7 @@ public string GetNavigationContext( .ThenBy(m => m.OrderOnPage) .ToList(); - int idx = ordered.IndexOf(mapping); + int idx = ordered.FindIndex(m => ReferenceEquals(m, mapping)); if (idx < 0) { return $"Section '{sectionKey}' could not be located in the ordered mapping list."; @@ -213,7 +260,7 @@ public string GetNavigationContext( } if (parent is not null) { - sb.AppendLine(CultureInfo.InvariantCulture, $"**Parent:** {parent.RawHeading} (`/{parent.Keys.First()}#{parent.AnchorId}`)"); + sb.AppendLine(CultureInfo.InvariantCulture, $"**Parent:** {parent.RawHeading} (`/{parent.Keys.FirstOrDefault() ?? parent.PrimaryKey}#{parent.AnchorId}`)"); sb.AppendLine(); } @@ -230,7 +277,7 @@ public string GetNavigationContext( } if (prev is not null) { - sb.AppendLine(CultureInfo.InvariantCulture, $"**Previous:** {prev.RawHeading} (`/{prev.Keys.First()}#{prev.AnchorId}`)"); + sb.AppendLine(CultureInfo.InvariantCulture, $"**Previous:** {prev.RawHeading} (`/{prev.Keys.FirstOrDefault() ?? prev.PrimaryKey}#{prev.AnchorId}`)"); } // Next section at same indent level in the same chapter @@ -247,13 +294,13 @@ public string GetNavigationContext( } if (next is not null) { - sb.AppendLine(CultureInfo.InvariantCulture, $"**Next:** {next.RawHeading} (`/{next.Keys.First()}#{next.AnchorId}`)"); + sb.AppendLine(CultureInfo.InvariantCulture, $"**Next:** {next.RawHeading} (`/{next.Keys.FirstOrDefault() ?? next.PrimaryKey}#{next.AnchorId}`)"); } // Siblings: all siblings sharing the same parent if (parent is not null) { - int parentIdx = ordered.IndexOf(parent); + int parentIdx = ordered.FindIndex(m => ReferenceEquals(m, parent)); var siblings = new List(); for (int i = parentIdx + 1; i < ordered.Count; i++) { @@ -270,7 +317,7 @@ public string GetNavigationContext( sb.AppendLine("**Sibling sections:**"); foreach (var s in siblings) { - sb.AppendLine(CultureInfo.InvariantCulture, $" - {s.RawHeading} (`/{s.Keys.First()}#{s.AnchorId}`)"); + sb.AppendLine(CultureInfo.InvariantCulture, $" - {s.RawHeading} (`/{s.Keys.FirstOrDefault() ?? s.PrimaryKey}#{s.AnchorId}`)"); } } } @@ -278,7 +325,7 @@ public string GetNavigationContext( return sb.ToString(); } - [McpServerTool(Title = "Get Chapter Summary", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Chapter Summary", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Get a structural overview of a book chapter: its top-level section headings in reading order, and the coding guidelines associated with that chapter. Useful for understanding what a chapter covers before diving in.")] public string GetChapterSummary( [Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) @@ -304,7 +351,7 @@ public string GetChapterSummary( foreach (var m in chapterMappings.Where(m => m.IndentLevel <= 1)) { string indent = m.IndentLevel == 0 ? "" : " "; - string link = $"`/{m.Keys.First()}#{m.AnchorId}`"; + string link = $"`/{m.Keys.FirstOrDefault() ?? m.PrimaryKey}#{m.AnchorId}`"; sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}- {m.RawHeading} ({link})"); } @@ -356,10 +403,11 @@ private static void ExtractNodeContent(HtmlNode node, StringBuilder sb) { foreach (var line in codeLines) { - // Remove the line-number span before extracting text - var lineNumberSpan = line.SelectSingleNode(".//span[contains(@class,'code-line-number')]"); + // Clone to avoid mutating the live HtmlDocument DOM + var lineClone = line.CloneNode(deep: true); + var lineNumberSpan = lineClone.SelectSingleNode(".//span[contains(@class,'code-line-number')]"); lineNumberSpan?.Remove(); - sb.AppendLine(HtmlEntity.DeEntitize(line.InnerText)); + sb.AppendLine(HtmlEntity.DeEntitize(lineClone.InnerText)); } } sb.AppendLine("```"); @@ -385,16 +433,6 @@ private static void ExtractNodeContent(HtmlNode node, StringBuilder sb) } } - private static string NormalizeExtension(string ext) => - ext.TrimStart('.').ToLowerInvariant() switch - { - "cs" => "csharp", - "vb" => "vbnet", - "fs" => "fsharp", - "" => "", - var e => e - }; - private static string FormatGuidelineType(GuidelineType type) => type switch { GuidelineType.Do => "DO", @@ -403,4 +441,7 @@ private static string NormalizeExtension(string ext) => GuidelineType.DoNot => "DO NOT", _ => "NOTE" }; + + [GeneratedRegex(@"^[A-Za-z0-9_-]{1,128}$")] + private static partial Regex AnchorIdRegex(); } diff --git a/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs index 4f26306e..9a71b810 100644 --- a/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs +++ b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs @@ -16,7 +16,7 @@ public BookGuidelinesTool(IGuidelinesService guidelinesService) _guidelinesService = guidelinesService; } - [McpServerTool(Title = "Get C# Guidelines", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get C# Guidelines", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Retrieve C# coding guidelines from the Essential C# book. Optionally filter by keyword, chapter number, or guideline type (do/consider/avoid/donot). The book contains guidelines covering naming conventions, error handling, LINQ, async/await, generics, and many other topics. Each guideline includes its chapter and subsection context.")] public string GetCSharpGuidelines( [Description("Optional keyword to filter guidelines by (searched in guideline text and subsection name).")] string? keyword = null, @@ -27,6 +27,11 @@ public string GetCSharpGuidelines( maxResults = Math.Clamp(maxResults, 1, 50); GuidelineType? typeFilter = ParseGuidelineType(type); + if (type is not null && typeFilter is null) + { + return "Invalid guideline type. Valid values: 'do', 'consider', 'avoid', 'donot' (also accepts 'do not', 'dont')."; + } + IEnumerable filtered = _guidelinesService.Guidelines; if (chapter.HasValue) @@ -62,7 +67,7 @@ public string GetCSharpGuidelines( return sb.ToString(); } - [McpServerTool(Title = "Get Guidelines By Topic", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Guidelines By Topic", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Search C# coding guidelines from the Essential C# book by topic or concept. More discoverable than filtering by chapter — finds all guidelines related to exceptions, naming, async, LINQ, generics, interfaces, and more. Results are ordered by relevance to the topic.")] public string GetGuidelinesByTopic( [Description("The topic or concept to search guidelines for (e.g., 'exception handling', 'naming', 'async', 'LINQ', 'generics', 'interface').")] string topic, @@ -112,7 +117,7 @@ public string GetGuidelinesByTopic( { if (string.IsNullOrWhiteSpace(input)) return null; - return input.Trim().ToLowerInvariant().Replace(" ", "").Replace("_", "") switch + return input.Trim().ToLowerInvariant().Replace(" ", "").Replace("_", "").Replace("'", "") switch { "do" => GuidelineType.Do, "consider" => GuidelineType.Consider, diff --git a/EssentialCSharp.Web/Tools/BookListingTool.cs b/EssentialCSharp.Web/Tools/BookListingTool.cs index a3ead64e..9de81f6a 100644 --- a/EssentialCSharp.Web/Tools/BookListingTool.cs +++ b/EssentialCSharp.Web/Tools/BookListingTool.cs @@ -18,7 +18,7 @@ public BookListingTool(IListingSourceCodeService listingService, ISiteMappingSer _siteMappingService = siteMappingService; } - [McpServerTool(Title = "Get Listing Source Code", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Listing Source Code", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Retrieve the complete source code for a specific numbered listing from the Essential C# book. Example: chapter=5, listing=3 retrieves Listing 5.3. Returns the code and its file type.")] public async Task GetListingSourceCode( [Description("The chapter number containing the listing (e.g., 5 for Chapter 5).")] int chapter, @@ -31,11 +31,11 @@ public async Task GetListingSourceCode( return $"Listing {chapter}.{listing} not found. Verify that both the chapter and listing numbers are correct."; } - string langHint = NormalizeExtension(response.FileExtension); + string langHint = BookToolHelpers.NormalizeExtension(response.FileExtension); return $"## Listing {response.ChapterNumber}.{response.ListingNumber}\n\n```{langHint}\n{response.Content}\n```"; } - [McpServerTool(Title = "Search Listings By Code", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Search Listings By Code", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Search all code listings in the Essential C# book for a specific code pattern, keyword, or identifier. Searches actual C# source code (not prose). Useful for finding examples of Task.WhenAll, yield return, IDisposable, pattern matching, and similar code constructs.")] public async Task SearchListingsByCode( [Description("The code pattern or keyword to search for in listing source code (case-insensitive substring match).")] string pattern, @@ -68,7 +68,7 @@ public async Task SearchListingsByCode( if (found >= maxResults) break; if (listing.Content.Contains(pattern, StringComparison.OrdinalIgnoreCase)) { - string langHint = NormalizeExtension(listing.FileExtension); + string langHint = BookToolHelpers.NormalizeExtension(listing.FileExtension); sb.AppendLine(CultureInfo.InvariantCulture, $"### Listing {listing.ChapterNumber}.{listing.ListingNumber}"); sb.AppendLine(CultureInfo.InvariantCulture, $"```{langHint}"); sb.AppendLine(listing.Content); @@ -87,14 +87,4 @@ public async Task SearchListingsByCode( sb.Insert(0, $"# Listings Containing '{pattern}' ({found} result{(found == 1 ? "" : "s")})\n\n"); return sb.ToString(); } - - private static string NormalizeExtension(string ext) => - ext.TrimStart('.').ToLowerInvariant() switch - { - "cs" => "csharp", - "vb" => "vbnet", - "fs" => "fsharp", - "" => "", - var e => e - }; } diff --git a/EssentialCSharp.Web/Tools/BookSearchTool.cs b/EssentialCSharp.Web/Tools/BookSearchTool.cs index a0a98d0c..aabac117 100644 --- a/EssentialCSharp.Web/Tools/BookSearchTool.cs +++ b/EssentialCSharp.Web/Tools/BookSearchTool.cs @@ -26,19 +26,30 @@ public sealed class BookSearchTool { private readonly AISearchService? _SearchService; private readonly ISiteMappingService _SiteMappingService; + private readonly IGuidelinesService _guidelinesService; - public BookSearchTool(IServiceProvider serviceProvider, ISiteMappingService siteMappingService) + public BookSearchTool(IServiceProvider serviceProvider, ISiteMappingService siteMappingService, IGuidelinesService guidelinesService) { _SearchService = serviceProvider.GetService(); _SiteMappingService = siteMappingService; + _guidelinesService = guidelinesService; } - [McpServerTool(Title = "Search Book Content", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Search Book Content", ReadOnly = true, Idempotent = true, OpenWorld = false), 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 (string.IsNullOrWhiteSpace(query)) + { + return "Query must not be empty."; + } + if (query.Length > 500) + { + return "Query is too long (maximum 500 characters)."; + } + if (_SearchService is null) { return "Book search is not available in this environment (AI services are not configured)."; @@ -76,7 +87,7 @@ public async Task SearchBookContent( return sb.ToString(); } - [McpServerTool(Title = "Get Chapter List", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Chapter List", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Get the table of contents for the Essential C# book, listing all chapters and their sections with navigation links.")] public string GetChapterList() { @@ -107,7 +118,7 @@ public string GetChapterList() return sb.ToString(); } - [McpServerTool(Title = "Get Chapter Sections", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Chapter Sections", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Get all sections and subsections in a specific chapter of the Essential C# book, in reading order. Returns each section's heading, slug, anchor link, and indent level. Use the returned slugs with other tools like GetSectionContent or GetNavigationContext.")] public string GetChapterSections( [Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) @@ -139,11 +150,16 @@ public string GetChapterSections( return sb.ToString(); } - [McpServerTool(Title = "Get Direct Content URL", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Direct Content URL", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Get the canonical deep-link URL for a specific book section or subsection. Returns a clickable URL that navigates directly to the section. Use this to include precise references in responses.")] public string GetDirectContentUrl( [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections or GetChapterList to find valid slugs.")] string sectionKey) { + if (string.IsNullOrWhiteSpace(sectionKey)) + { + return "Section key must not be empty. Use GetChapterSections or GetChapterList to discover valid section slugs."; + } + SiteMapping? mapping = _SiteMappingService.SiteMappings.Find(sectionKey); if (mapping is null) { @@ -159,7 +175,7 @@ public string GetDirectContentUrl( $"URL: {url}"; } - [McpServerTool(Title = "Get Book Metadata", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Book Metadata", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Get citation-quality metadata for the Essential C# book: title, authors, edition, C# version covered, ISBN, publisher, and website URL. Use this when generating citations or when asked which edition or C# version the book covers.")] public string GetBookMetadata() { @@ -176,7 +192,7 @@ public string GetBookMetadata() """; } - [McpServerTool(Title = "Lookup Concept", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Lookup Concept", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Find all sections in the Essential C# book that cover a specific C# concept. Combines section heading search with semantic vector search (when available) to give broad coverage. Returns section slugs, chapter numbers, and direct links.")] public async Task LookupConcept( [Description("The C# concept, feature, or topic to find in the book (e.g., 'LINQ', 'async/await', 'pattern matching', 'generics').")] string concept, @@ -186,6 +202,10 @@ public async Task LookupConcept( { return "Concept must not be empty."; } + if (concept.Length > 500) + { + return "Concept is too long (maximum 500 characters)."; + } // Heading / key text search var headingMatches = _SiteMappingService.SiteMappings @@ -247,7 +267,7 @@ public async Task LookupConcept( return sb.ToString(); } - [McpServerTool(Title = "Check Topic Coverage", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Check Topic Coverage", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Determine whether and how thoroughly the Essential C# book covers a given topic. Returns a coverage assessment: 'Comprehensive', 'Mentioned', or 'Not found in headings'. Use this before citing the book to calibrate confidence.")] public async Task CheckTopicCoverage( [Description("The C# topic, feature, or concept to check (e.g., 'source generators', 'records', 'LINQ').")] string topic, @@ -257,6 +277,10 @@ public async Task CheckTopicCoverage( { return "Topic must not be empty."; } + if (topic.Length > 500) + { + return "Topic is too long (maximum 500 characters)."; + } // Heading search var headingMatches = _SiteMappingService.SiteMappings @@ -280,23 +304,17 @@ public async Task CheckTopicCoverage( sb.AppendLine(); string assessment; - if (hasHeadingCoverage && (hasSemanticCoverage || !semanticAvailable)) + if (hasHeadingCoverage) { assessment = "**Comprehensive** — dedicated section headings found"; } - else if (hasHeadingCoverage) - { - assessment = "**Mentioned** — appears in section headings"; - } else if (hasSemanticCoverage) { - assessment = "**Mentioned** — referenced in book content but no dedicated heading"; + assessment = "**Mentioned** — referenced in book content but no dedicated section heading"; } else if (!semanticAvailable) { - assessment = hasHeadingCoverage - ? "**Mentioned** — appears in section headings (semantic search unavailable for deeper analysis)" - : "**Not found in headings** — semantic search unavailable; topic may still be discussed in prose"; + assessment = "**Not found in headings** — semantic search unavailable; topic may still be discussed in prose"; } else { @@ -319,8 +337,8 @@ public async Task CheckTopicCoverage( return sb.ToString(); } - [McpServerTool(Title = "Find Book Help For Diagnostic", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), - Description("Find Essential C# book sections and guidelines that help explain a C# compiler error, warning, or runtime exception. Accepts a CS diagnostic code (e.g., 'CS8600') or a plain description (e.g., 'null reference exception', 'cannot implicitly convert'). Returns relevant book sections and guidelines.")] + [McpServerTool(Title = "Find Book Help For Diagnostic", ReadOnly = true, Idempotent = true, OpenWorld = false), + Description("Find Essential C# book sections, content, and coding guidelines that help explain a C# compiler error, warning, or runtime exception. Accepts a CS diagnostic code (e.g., 'CS8600') or a plain description (e.g., 'null reference exception', 'cannot implicitly convert'). Returns relevant sections, explanatory prose, and related guidelines.")] public async Task FindBookHelpForDiagnostic( [Description("A C# compiler diagnostic code (e.g., 'CS8600', 'CS0029') or a plain error description (e.g., 'null reference exception', 'async method lacks await').")] string diagnostic, CancellationToken cancellationToken = default) @@ -329,10 +347,58 @@ public async Task FindBookHelpForDiagnostic( { return "Diagnostic must not be empty."; } + if (diagnostic.Length > 500) + { + return "Diagnostic is too long (maximum 500 characters)."; + } - // Map well-known CS codes to relevant search topics string searchTerm = MapDiagnosticToTopic(diagnostic); + // Heading search + var headingMatches = _SiteMappingService.SiteMappings + .Where(m => m.RawHeading.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) + .Take(5) + .ToList(); + + // Vector search (buffered so we can check for any results before writing header) + bool hasVectorResults = false; + var vectorSb = new StringBuilder(); + if (_SearchService is not null) + { + var vectorResults = await _SearchService.ExecuteVectorSearch(searchTerm, cancellationToken: cancellationToken); + if (vectorResults.Count > 0) + { + hasVectorResults = true; + vectorSb.AppendLine("## Relevant Book Content"); + int count = 0; + foreach (var r in vectorResults) + { + if (count++ >= 3) break; + if (!string.IsNullOrEmpty(r.Record.Heading)) + { + vectorSb.AppendLine(CultureInfo.InvariantCulture, $"**{r.Record.Heading}** (Ch. {r.Record.ChapterNumber})"); + } + vectorSb.AppendLine(r.Record.ChunkText); + vectorSb.AppendLine(); + } + } + } + + // Guidelines search + var guidelineMatches = _guidelinesService.Guidelines + .Where(g => g.Guideline.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) + || g.SanitizedSubsection.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) + .Take(3) + .ToList(); + + if (headingMatches.Count == 0 && !hasVectorResults && guidelineMatches.Count == 0) + { + string semanticNote = _SearchService is null + ? " Semantic search is also unavailable in this environment." + : ""; + return $"No book content or guidelines found for '{diagnostic}'.{semanticNote} Try a broader description or use GetChapterList to explore the table of contents."; + } + var sb = new StringBuilder(); sb.AppendLine(CultureInfo.InvariantCulture, $"# Book Help for: {diagnostic}"); if (!string.Equals(searchTerm, diagnostic, StringComparison.OrdinalIgnoreCase)) @@ -341,12 +407,6 @@ public async Task FindBookHelpForDiagnostic( } sb.AppendLine(); - // Heading search on mapped topic - var headingMatches = _SiteMappingService.SiteMappings - .Where(m => m.RawHeading.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) - .Take(5) - .ToList(); - if (headingMatches.Count > 0) { sb.AppendLine("## Relevant Book Sections"); @@ -358,41 +418,36 @@ public async Task FindBookHelpForDiagnostic( sb.AppendLine(); } - // Vector search - if (_SearchService is not null) + if (vectorSb.Length > 0) { - var vectorResults = await _SearchService.ExecuteVectorSearch(searchTerm, cancellationToken: cancellationToken); - if (vectorResults.Count > 0) - { - sb.AppendLine("## Relevant Book Content"); - int count = 0; - foreach (var r in vectorResults) - { - if (count++ >= 3) break; - if (!string.IsNullOrEmpty(r.Record.Heading)) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"**{r.Record.Heading}** (Ch. {r.Record.ChapterNumber})"); - } - sb.AppendLine(r.Record.ChunkText); - sb.AppendLine(); - } - } + sb.Append(vectorSb); } - if (headingMatches.Count == 0 && _SearchService is null) + if (guidelineMatches.Count > 0) { - sb.AppendLine(CultureInfo.InvariantCulture, $"No sections found for '{searchTerm}'. Semantic search is unavailable in this environment."); + sb.AppendLine("## Related Guidelines"); + foreach (var g in guidelineMatches) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"**[{FormatGuidelineType(g.Type)}]** {g.Guideline}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" — Chapter {g.ChapterNumber}: {g.ChapterTitle} / {g.SanitizedSubsection}"); + sb.AppendLine(); + } } return sb.ToString(); } - [McpServerTool(Title = "Find Related Sections", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Find Related Sections", ReadOnly = true, Idempotent = true, OpenWorld = false), Description("Find other sections in the Essential C# book that are semantically related to a given section. Uses the section heading as a search query to discover thematically connected content across the entire book. Requires AI services to be configured.")] public async Task FindRelatedSections( [Description("The section slug/key to find related content for (e.g., 'async-await'). Use GetChapterSections to get valid slugs.")] string sectionKey, CancellationToken cancellationToken = default) { + if (string.IsNullOrWhiteSpace(sectionKey)) + { + return "Section key must not be empty. Use GetChapterSections to discover valid section slugs."; + } + SiteMapping? mapping = _SiteMappingService.SiteMappings.Find(sectionKey); if (mapping is null) { @@ -418,7 +473,7 @@ public async Task FindRelatedSections( { string heading = r.Record.Heading ?? ""; if (!seen.Add(heading)) continue; - if (count++ >= 5) break; + if (count++ >= 3) break; // Find the SiteMapping for this heading to get the link SiteMapping? relatedMapping = _SiteMappingService.SiteMappings @@ -442,6 +497,15 @@ public async Task FindRelatedSections( return sb.ToString(); } + private static string FormatGuidelineType(GuidelineType type) => type switch + { + GuidelineType.Do => "DO", + GuidelineType.Consider => "CONSIDER", + GuidelineType.Avoid => "AVOID", + GuidelineType.DoNot => "DO NOT", + _ => "NOTE" + }; + private static readonly Dictionary DiagnosticMap = new(StringComparer.OrdinalIgnoreCase) { // Nullable reference types diff --git a/EssentialCSharp.Web/Tools/BookToolHelpers.cs b/EssentialCSharp.Web/Tools/BookToolHelpers.cs new file mode 100644 index 00000000..62c7aae7 --- /dev/null +++ b/EssentialCSharp.Web/Tools/BookToolHelpers.cs @@ -0,0 +1,14 @@ +namespace EssentialCSharp.Web.Tools; + +internal static class BookToolHelpers +{ + internal static string NormalizeExtension(string ext) => + ext.TrimStart('.').ToLowerInvariant() switch + { + "cs" => "csharp", + "vb" => "vbnet", + "fs" => "fsharp", + "" => "", + var e => e + }; +} From 1aa434403e3bb55027a69d48640e0cbc489c59ff Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 24 Apr 2026 23:00:03 -0700 Subject: [PATCH 06/14] MCP setup page: document all 17 tools with descriptions and parameters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Views/McpSetup/Index.cshtml | 330 +++++++++++++++++- 1 file changed, 320 insertions(+), 10 deletions(-) diff --git a/EssentialCSharp.Web/Views/McpSetup/Index.cshtml b/EssentialCSharp.Web/Views/McpSetup/Index.cshtml index 8876ec8b..5fcabc01 100644 --- a/EssentialCSharp.Web/Views/McpSetup/Index.cshtml +++ b/EssentialCSharp.Web/Views/McpSetup/Index.cshtml @@ -259,9 +259,11 @@ -

Once connected, your AI client has access to the following tools:

+

Once connected, your AI client has access to 17 tools organized into four groups:

-
+
Search & Discovery
+ +
search_book_content @@ -270,19 +272,101 @@

Semantic vector search over all Essential C# book chapters. Returns the most relevant text - chunks with chapter number, section heading, and relevance score. Use this to ask questions - like "How does async/await work in C#?" or "What is pattern matching?" + chunks with chapter number, section heading, and relevance score. Use for questions like + "How does async/await work?" or "What is pattern matching?" +

+
+
Input
+
query — natural-language C# concept or question
+
Returns
+
Ranked text chunks with chapter, section, and relevance score
+
+
+
+ +
+ + + lookup_concept + Find all sections covering a concept + +
+

+ Combines section heading text-match with semantic search to find every place a C# concept + appears in the book. Good for topics like "LINQ", "generics", or "async/await".

Input
-
query — a natural-language description of the C# concept to find
+
concept — C# feature or topic name
Returns
-
Ranked text chunks with chapter, section, and score context
+
Section headings with chapter numbers and deep links
-
+
+ + + check_topic_coverage + Assess how thoroughly a topic is covered + +
+

+ Tells the AI how well the book covers a given topic — "Comprehensive" (dedicated sections), + "Mentioned" (in prose only), or "Not covered". Helps AI calibrate its confidence before citing the book. +

+
+
Input
+
topic — C# concept to check (e.g., "source generators", "records")
+
Returns
+
Coverage assessment with relevant section links
+
+
+
+ +
+ + + find_book_help_for_diagnostic + Map compiler errors to book sections + +
+

+ Given a C# compiler error code (e.g. CS8600) or a plain description (e.g. "null reference exception"), + returns the most relevant book sections and guidelines. Especially useful when debugging C# code with AI assistance. +

+
+
Input
+
diagnostic — a CS error code or plain error description
+
Returns
+
Relevant sections, prose excerpts, and related coding guidelines
+
+
+
+ +
+ + + find_related_sections + Semantically related sections + +
+

+ Given a section slug, finds other book sections with similar themes using vector search. + Great for "what else should I read after this?" recommendations. +

+
+
Input
+
sectionKey — section slug (use get_chapter_sections to find valid values)
+
Returns
+
Up to 3 related sections with headings, links, and content previews
+
+
+
+ +
Navigation & Content
+ +
get_chapter_list @@ -290,17 +374,243 @@

- Returns the full table of contents for Essential C# — all chapters and their sections with - navigation links. Useful for orientation or when you want to explore a specific chapter. + Returns the full table of contents — all chapters and sections with navigation links. + A good starting point for any AI session with this server.

Input
None
Returns
-
Markdown table of contents with chapter titles, sections, and links
+
Hierarchical chapter and section list with links
+
+
+
+ +
+ + + get_chapter_sections + All sections in one chapter + +
+

+ Lists every section and subsection in a given chapter with headings, slugs, and anchor links. + Use the returned slugs with content tools like get_section_content or get_navigation_context. +

+
+
Input
+
chapter — chapter number (e.g., 10)
+
Returns
+
Indented section list with slugs and deep links
+
+
+
+ +
+ + + get_section_content + Read a section's prose text + +
+

+ Extracts and returns the readable text of a specific section from the HTML chapter pages, + with code examples preserved. Use this to quote or summarize specific book passages. +

+
+
Input
+
sectionKey, optional maxChars (500–8000, default 4000)
+
Returns
+
Section heading, chapter context, and formatted prose text
+ +
+ + + get_navigation_context + Breadcrumbs, prev/next, siblings + +
+

+ Returns the full navigation context for a section: breadcrumb path, previous section, + next section, parent, and sibling list. Powers AI "what should I read next?" flows. +

+
+
Input
+
sectionKey — section slug
+
Returns
+
Breadcrumbs, previous, next, parent, and sibling section links
+
+
+
+ +
+ + + get_chapter_summary + Chapter overview with guidelines + +
+

+ Returns a chapter overview: all sections in reading order plus the most relevant coding + guidelines for that chapter. Ideal for "give me an overview of chapter N" prompts. +

+
+
Input
+
chapter — chapter number
+
Returns
+
Chapter title, section list with links, and relevant guidelines
+
+
+
+ +
+ + + get_direct_content_url + Deep-link URL for any section + +
+

+ Returns the canonical URL for any section or subsection so AI can include clickable + references in its answers (e.g. https://essentialcsharp.com/async-await#awaitExpression). +

+
+
Input
+
sectionKey — section slug
+
Returns
+
Section heading, chapter info, and full URL
+
+
+
+ +
Code Listings
+ +
+ + + get_listing_source_code + Source code for a numbered listing + +
+

+ Retrieves the complete source code for any numbered listing in the book + (e.g. "show me Listing 10.3"). Returns the code with its language hint. +

+
+
Input
+
chapter, listing — e.g., chapter=10, listing=3 for Listing 10.3
+
Returns
+
Formatted code block with language hint
+
+
+
+ +
+ + + get_listing_with_context + Listing + book explanation + +
+

+ Gets a listing's source code together with the semantically nearest prose excerpts that + explain it. Useful for "explain this listing" prompts. +

+
+
Input
+
chapter, listing
+
Returns
+
Source code + explanatory prose from nearby sections
+
+
+
+ +
+ + + search_listings_by_code + Search source files for a code pattern + +
+

+ Searches all listing source files for a code pattern, identifier, or construct + (e.g. "Task.WhenAll", "yield return", "IDisposable"). Searches actual C#, not prose. +

+
+
Input
+
pattern — case-insensitive substring; optional maxResults (1–20, default 10)
+
Returns
+
Matching source listings with chapter.listing numbering
+
+
+
+ +
Coding Guidelines
+ +
+ + + get_csharp_guidelines + 241 C# coding guidelines, filterable + +
+

+ Access the book's 241 C# coding guidelines — DO, CONSIDER, AVOID, and DO NOT rules + covering naming conventions, error handling, LINQ, async/await, generics, and more. + Filter by keyword, chapter, or rule type. +

+
+
Input
+
Optional: keyword, chapter, type (do/consider/avoid/donot), maxResults
+
Returns
+
Guidelines with type label, chapter, and subsection context
+
+
+
+ +
+ + + get_guidelines_by_topic + Guidelines ranked by topic relevance + +
+

+ Finds guidelines related to a concept (e.g. "exception handling", "naming", "interfaces") + and ranks them by how closely they match. More discoverable than filtering by chapter number. +

+
+
Input
+
topic — concept or keyword; optional maxResults (1–30, default 15)
+
Returns
+
Guidelines ranked by relevance with type labels and chapter context
+
+
+
+ +
+ + + get_book_metadata + Citation-quality book info + +
+

+ Returns title, authors, edition, C# version covered, ISBN, publisher, and website URL. + Useful when the AI needs to cite the book or answer "which edition is this?" +

+
+
Input
+
None
+
Returns
+
Title, 9th Edition, C# 13, authors, ISBN-13, publisher, URL
+
+
+
+ From f93b33e9409c1df77c55b8f084de4c4f7b260194 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 24 Apr 2026 23:02:30 -0700 Subject: [PATCH 07/14] fix: restore Destructive = false on all MCP tool annotations The MCP spec (and C# SDK) default destructiveHint to true. Explicitly setting Destructive = false is required so clients know these read-only book search tools are non-destructive. Note: per spec, destructiveHint is 'only meaningful when readOnlyHint == false', but the SDK still emits the value on the wire so explicit false is the safe, correct choice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- EssentialCSharp.Web/Tools/BookContentTool.cs | 10 +++++----- .../Tools/BookGuidelinesTool.cs | 6 +++--- EssentialCSharp.Web/Tools/BookListingTool.cs | 6 +++--- EssentialCSharp.Web/Tools/BookSearchTool.cs | 20 +++++++++---------- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/EssentialCSharp.Web/Tools/BookContentTool.cs b/EssentialCSharp.Web/Tools/BookContentTool.cs index 24c7d40f..cb484945 100644 --- a/EssentialCSharp.Web/Tools/BookContentTool.cs +++ b/EssentialCSharp.Web/Tools/BookContentTool.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Globalization; using System.Text; using System.Text.RegularExpressions; @@ -33,7 +33,7 @@ public BookContentTool( _searchService = serviceProvider.GetService(); } - [McpServerTool(Title = "Get Section Content", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Section Content", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Retrieve the prose content of a specific book section identified by its slug/key (e.g., 'hello-world', 'creating-editing-compiling-and-running-c-source-code'). Returns the section text with code examples preserved. Use GetChapterSections to discover available slugs.")] public async Task GetSectionContent( [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections to get valid slugs.")] string sectionKey, @@ -145,7 +145,7 @@ public async Task GetSectionContent( return body.Length == 0 ? $"No content found after section heading '{mapping.RawHeading}'." : header.Append(body).ToString(); } - [McpServerTool(Title = "Get Listing With Context", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Listing With Context", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Retrieve a specific book listing's source code together with the semantic book content that explains it. Combines code from GetListingSourceCode with related explanatory text found via search. Ideal for understanding what a listing demonstrates.")] public async Task GetListingWithContext( [Description("The chapter number of the listing.")] int chapter, @@ -191,7 +191,7 @@ public async Task GetListingWithContext( return sb.ToString(); } - [McpServerTool(Title = "Get Navigation Context", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Navigation Context", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Get the navigation context for a book section: its breadcrumb path, the previous and next sections, its parent section, and its sibling sections. Useful for understanding where a section sits in the book's structure.")] public string GetNavigationContext( [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections to get valid slugs.")] string sectionKey) @@ -325,7 +325,7 @@ public string GetNavigationContext( return sb.ToString(); } - [McpServerTool(Title = "Get Chapter Summary", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Chapter Summary", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Get a structural overview of a book chapter: its top-level section headings in reading order, and the coding guidelines associated with that chapter. Useful for understanding what a chapter covers before diving in.")] public string GetChapterSummary( [Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) diff --git a/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs index 9a71b810..368c9ada 100644 --- a/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs +++ b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Globalization; using System.Text; using EssentialCSharp.Web.Services; @@ -16,7 +16,7 @@ public BookGuidelinesTool(IGuidelinesService guidelinesService) _guidelinesService = guidelinesService; } - [McpServerTool(Title = "Get C# Guidelines", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get C# Guidelines", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Retrieve C# coding guidelines from the Essential C# book. Optionally filter by keyword, chapter number, or guideline type (do/consider/avoid/donot). The book contains guidelines covering naming conventions, error handling, LINQ, async/await, generics, and many other topics. Each guideline includes its chapter and subsection context.")] public string GetCSharpGuidelines( [Description("Optional keyword to filter guidelines by (searched in guideline text and subsection name).")] string? keyword = null, @@ -67,7 +67,7 @@ public string GetCSharpGuidelines( return sb.ToString(); } - [McpServerTool(Title = "Get Guidelines By Topic", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Guidelines By Topic", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Search C# coding guidelines from the Essential C# book by topic or concept. More discoverable than filtering by chapter — finds all guidelines related to exceptions, naming, async, LINQ, generics, interfaces, and more. Results are ordered by relevance to the topic.")] public string GetGuidelinesByTopic( [Description("The topic or concept to search guidelines for (e.g., 'exception handling', 'naming', 'async', 'LINQ', 'generics', 'interface').")] string topic, diff --git a/EssentialCSharp.Web/Tools/BookListingTool.cs b/EssentialCSharp.Web/Tools/BookListingTool.cs index 9de81f6a..827bc3a7 100644 --- a/EssentialCSharp.Web/Tools/BookListingTool.cs +++ b/EssentialCSharp.Web/Tools/BookListingTool.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Globalization; using System.Text; using EssentialCSharp.Web.Services; @@ -18,7 +18,7 @@ public BookListingTool(IListingSourceCodeService listingService, ISiteMappingSer _siteMappingService = siteMappingService; } - [McpServerTool(Title = "Get Listing Source Code", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Listing Source Code", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Retrieve the complete source code for a specific numbered listing from the Essential C# book. Example: chapter=5, listing=3 retrieves Listing 5.3. Returns the code and its file type.")] public async Task GetListingSourceCode( [Description("The chapter number containing the listing (e.g., 5 for Chapter 5).")] int chapter, @@ -35,7 +35,7 @@ public async Task GetListingSourceCode( return $"## Listing {response.ChapterNumber}.{response.ListingNumber}\n\n```{langHint}\n{response.Content}\n```"; } - [McpServerTool(Title = "Search Listings By Code", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Search Listings By Code", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Search all code listings in the Essential C# book for a specific code pattern, keyword, or identifier. Searches actual C# source code (not prose). Useful for finding examples of Task.WhenAll, yield return, IDisposable, pattern matching, and similar code constructs.")] public async Task SearchListingsByCode( [Description("The code pattern or keyword to search for in listing source code (case-insensitive substring match).")] string pattern, diff --git a/EssentialCSharp.Web/Tools/BookSearchTool.cs b/EssentialCSharp.Web/Tools/BookSearchTool.cs index aabac117..5ba9be65 100644 --- a/EssentialCSharp.Web/Tools/BookSearchTool.cs +++ b/EssentialCSharp.Web/Tools/BookSearchTool.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Globalization; using System.Text; using System.Text.RegularExpressions; @@ -35,7 +35,7 @@ public BookSearchTool(IServiceProvider serviceProvider, ISiteMappingService site _guidelinesService = guidelinesService; } - [McpServerTool(Title = "Search Book Content", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Search Book Content", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), 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, @@ -87,7 +87,7 @@ public async Task SearchBookContent( return sb.ToString(); } - [McpServerTool(Title = "Get Chapter List", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Chapter List", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Get the table of contents for the Essential C# book, listing all chapters and their sections with navigation links.")] public string GetChapterList() { @@ -118,7 +118,7 @@ public string GetChapterList() return sb.ToString(); } - [McpServerTool(Title = "Get Chapter Sections", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Chapter Sections", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Get all sections and subsections in a specific chapter of the Essential C# book, in reading order. Returns each section's heading, slug, anchor link, and indent level. Use the returned slugs with other tools like GetSectionContent or GetNavigationContext.")] public string GetChapterSections( [Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) @@ -150,7 +150,7 @@ public string GetChapterSections( return sb.ToString(); } - [McpServerTool(Title = "Get Direct Content URL", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Direct Content URL", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Get the canonical deep-link URL for a specific book section or subsection. Returns a clickable URL that navigates directly to the section. Use this to include precise references in responses.")] public string GetDirectContentUrl( [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections or GetChapterList to find valid slugs.")] string sectionKey) @@ -175,7 +175,7 @@ public string GetDirectContentUrl( $"URL: {url}"; } - [McpServerTool(Title = "Get Book Metadata", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Book Metadata", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Get citation-quality metadata for the Essential C# book: title, authors, edition, C# version covered, ISBN, publisher, and website URL. Use this when generating citations or when asked which edition or C# version the book covers.")] public string GetBookMetadata() { @@ -192,7 +192,7 @@ public string GetBookMetadata() """; } - [McpServerTool(Title = "Lookup Concept", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Lookup Concept", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Find all sections in the Essential C# book that cover a specific C# concept. Combines section heading search with semantic vector search (when available) to give broad coverage. Returns section slugs, chapter numbers, and direct links.")] public async Task LookupConcept( [Description("The C# concept, feature, or topic to find in the book (e.g., 'LINQ', 'async/await', 'pattern matching', 'generics').")] string concept, @@ -267,7 +267,7 @@ public async Task LookupConcept( return sb.ToString(); } - [McpServerTool(Title = "Check Topic Coverage", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Check Topic Coverage", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Determine whether and how thoroughly the Essential C# book covers a given topic. Returns a coverage assessment: 'Comprehensive', 'Mentioned', or 'Not found in headings'. Use this before citing the book to calibrate confidence.")] public async Task CheckTopicCoverage( [Description("The C# topic, feature, or concept to check (e.g., 'source generators', 'records', 'LINQ').")] string topic, @@ -337,7 +337,7 @@ public async Task CheckTopicCoverage( return sb.ToString(); } - [McpServerTool(Title = "Find Book Help For Diagnostic", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Find Book Help For Diagnostic", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Find Essential C# book sections, content, and coding guidelines that help explain a C# compiler error, warning, or runtime exception. Accepts a CS diagnostic code (e.g., 'CS8600') or a plain description (e.g., 'null reference exception', 'cannot implicitly convert'). Returns relevant sections, explanatory prose, and related guidelines.")] public async Task FindBookHelpForDiagnostic( [Description("A C# compiler diagnostic code (e.g., 'CS8600', 'CS0029') or a plain error description (e.g., 'null reference exception', 'async method lacks await').")] string diagnostic, @@ -437,7 +437,7 @@ public async Task FindBookHelpForDiagnostic( return sb.ToString(); } - [McpServerTool(Title = "Find Related Sections", ReadOnly = true, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Find Related Sections", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Find other sections in the Essential C# book that are semantically related to a given section. Uses the section heading as a search query to discover thematically connected content across the entire book. Requires AI services to be configured.")] public async Task FindRelatedSections( [Description("The section slug/key to find related content for (e.g., 'async-await'). Use GetChapterSections to get valid slugs.")] string sectionKey, From bd6311254d7b62be9b57f9d10de2bf912675ddb9 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 24 Apr 2026 23:03:48 -0700 Subject: [PATCH 08/14] fix: resolve remaining CodeQL issues from PR #876 - BookSearchTool: collapse else-if(!semanticAvailable)+else into single else with ternary to remove constant-condition CodeQL finding - BookGuidelinesTool: use pattern match (is int / is GuidelineType) to extract nullable locals before lambda capture, removing nullable dereference warnings at lines 38 and 41 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- EssentialCSharp.Web/Tools/BookGuidelinesTool.cs | 8 ++++---- EssentialCSharp.Web/Tools/BookSearchTool.cs | 8 +++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs index 368c9ada..8d6c8eaa 100644 --- a/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs +++ b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs @@ -34,11 +34,11 @@ public string GetCSharpGuidelines( IEnumerable filtered = _guidelinesService.Guidelines; - if (chapter.HasValue) - filtered = filtered.Where(g => g.ChapterNumber == chapter.Value); + if (chapter is int chapterValue) + filtered = filtered.Where(g => g.ChapterNumber == chapterValue); - if (typeFilter.HasValue) - filtered = filtered.Where(g => g.Type == typeFilter.Value); + if (typeFilter is GuidelineType typeFilterValue) + filtered = filtered.Where(g => g.Type == typeFilterValue); if (!string.IsNullOrWhiteSpace(keyword)) filtered = filtered.Where(g => diff --git a/EssentialCSharp.Web/Tools/BookSearchTool.cs b/EssentialCSharp.Web/Tools/BookSearchTool.cs index 5ba9be65..eda419a6 100644 --- a/EssentialCSharp.Web/Tools/BookSearchTool.cs +++ b/EssentialCSharp.Web/Tools/BookSearchTool.cs @@ -312,13 +312,11 @@ public async Task CheckTopicCoverage( { assessment = "**Mentioned** — referenced in book content but no dedicated section heading"; } - else if (!semanticAvailable) - { - assessment = "**Not found in headings** — semantic search unavailable; topic may still be discussed in prose"; - } else { - assessment = "**Not covered** — not found in section headings or semantic search"; + assessment = semanticAvailable + ? "**Not covered** — not found in section headings or semantic search" + : "**Not found in headings** — semantic search unavailable; topic may still be discussed in prose"; } sb.AppendLine(CultureInfo.InvariantCulture, $"**Assessment:** {assessment}"); From a366522b3fedc4c15d23fa3bb057845cb3f089ef Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 24 Apr 2026 23:09:03 -0700 Subject: [PATCH 09/14] refactor: delete BookToolHelpers, inline extension normalization at call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper class only had one method (NormalizeExtension) used in 3 places. The VB.NET/F# cases were dead code — all 1,396 real book listings are .cs. Simplified to a ternary inline at each call site; non-cs extensions pass through unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- EssentialCSharp.Web/Tools/BookContentTool.cs | 2 +- EssentialCSharp.Web/Tools/BookListingTool.cs | 4 ++-- EssentialCSharp.Web/Tools/BookToolHelpers.cs | 14 -------------- 3 files changed, 3 insertions(+), 17 deletions(-) delete mode 100644 EssentialCSharp.Web/Tools/BookToolHelpers.cs diff --git a/EssentialCSharp.Web/Tools/BookContentTool.cs b/EssentialCSharp.Web/Tools/BookContentTool.cs index cb484945..e77d7962 100644 --- a/EssentialCSharp.Web/Tools/BookContentTool.cs +++ b/EssentialCSharp.Web/Tools/BookContentTool.cs @@ -158,7 +158,7 @@ public async Task GetListingWithContext( return $"Listing {chapter}.{listing} not found. Verify the chapter and listing numbers."; } - string langHint = BookToolHelpers.NormalizeExtension(response.FileExtension); + string langHint = response.FileExtension == "cs" ? "csharp" : response.FileExtension; var sb = new StringBuilder(); sb.AppendLine(CultureInfo.InvariantCulture, $"## Listing {response.ChapterNumber}.{response.ListingNumber}"); sb.AppendLine(CultureInfo.InvariantCulture, $"```{langHint}"); diff --git a/EssentialCSharp.Web/Tools/BookListingTool.cs b/EssentialCSharp.Web/Tools/BookListingTool.cs index 827bc3a7..19b70ee3 100644 --- a/EssentialCSharp.Web/Tools/BookListingTool.cs +++ b/EssentialCSharp.Web/Tools/BookListingTool.cs @@ -31,7 +31,7 @@ public async Task GetListingSourceCode( return $"Listing {chapter}.{listing} not found. Verify that both the chapter and listing numbers are correct."; } - string langHint = BookToolHelpers.NormalizeExtension(response.FileExtension); + string langHint = response.FileExtension == "cs" ? "csharp" : response.FileExtension; return $"## Listing {response.ChapterNumber}.{response.ListingNumber}\n\n```{langHint}\n{response.Content}\n```"; } @@ -68,7 +68,7 @@ public async Task SearchListingsByCode( if (found >= maxResults) break; if (listing.Content.Contains(pattern, StringComparison.OrdinalIgnoreCase)) { - string langHint = BookToolHelpers.NormalizeExtension(listing.FileExtension); + string langHint = listing.FileExtension == "cs" ? "csharp" : listing.FileExtension; sb.AppendLine(CultureInfo.InvariantCulture, $"### Listing {listing.ChapterNumber}.{listing.ListingNumber}"); sb.AppendLine(CultureInfo.InvariantCulture, $"```{langHint}"); sb.AppendLine(listing.Content); diff --git a/EssentialCSharp.Web/Tools/BookToolHelpers.cs b/EssentialCSharp.Web/Tools/BookToolHelpers.cs deleted file mode 100644 index 62c7aae7..00000000 --- a/EssentialCSharp.Web/Tools/BookToolHelpers.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace EssentialCSharp.Web.Tools; - -internal static class BookToolHelpers -{ - internal static string NormalizeExtension(string ext) => - ext.TrimStart('.').ToLowerInvariant() switch - { - "cs" => "csharp", - "vb" => "vbnet", - "fs" => "fsharp", - "" => "", - var e => e - }; -} From 2d5678f4746d8bc06df4e0310ffdeb6418f06f49 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 25 Apr 2026 21:37:46 -0700 Subject: [PATCH 10/14] refactor: remove GetBookMetadata tool; fix BookContentTool foreach->Select - Remove GetBookMetadata() MCP tool and trim BookMetadata class to SiteUrl only - Fix PR #876 feedback: refactor foreach+clone into codeLines.Select(line => line.CloneNode(deep:true)) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- EssentialCSharp.Web/Tools/BookContentTool.cs | 4 +--- EssentialCSharp.Web/Tools/BookSearchTool.cs | 24 ------------------- .../Views/McpSetup/Index.cshtml | 19 --------------- 3 files changed, 1 insertion(+), 46 deletions(-) diff --git a/EssentialCSharp.Web/Tools/BookContentTool.cs b/EssentialCSharp.Web/Tools/BookContentTool.cs index e77d7962..9078d848 100644 --- a/EssentialCSharp.Web/Tools/BookContentTool.cs +++ b/EssentialCSharp.Web/Tools/BookContentTool.cs @@ -401,10 +401,8 @@ private static void ExtractNodeContent(HtmlNode node, StringBuilder sb) var codeLines = node.SelectNodes(".//div[contains(@class,'code-line')]"); if (codeLines is not null) { - foreach (var line in codeLines) + foreach (var lineClone in codeLines.Select(line => line.CloneNode(deep: true))) { - // Clone to avoid mutating the live HtmlDocument DOM - var lineClone = line.CloneNode(deep: true); var lineNumberSpan = lineClone.SelectSingleNode(".//span[contains(@class,'code-line-number')]"); lineNumberSpan?.Remove(); sb.AppendLine(HtmlEntity.DeEntitize(lineClone.InnerText)); diff --git a/EssentialCSharp.Web/Tools/BookSearchTool.cs b/EssentialCSharp.Web/Tools/BookSearchTool.cs index eda419a6..d08d1861 100644 --- a/EssentialCSharp.Web/Tools/BookSearchTool.cs +++ b/EssentialCSharp.Web/Tools/BookSearchTool.cs @@ -9,15 +9,8 @@ namespace EssentialCSharp.Web.Tools; -// Book metadata constants — update here when edition changes. file static class BookMetadata { - public const string Title = "Essential C#"; - public const string Edition = "9th Edition"; - public const string CSharpVersion = "C# 13.0"; - public const string Authors = "Mark and Benjamin Michaelis"; - public const string Publisher = "Addison-Wesley Professional"; - public const string Isbn13 = "978-0-13-576056-5"; public const string SiteUrl = "https://essentialcsharp.com"; } @@ -175,23 +168,6 @@ public string GetDirectContentUrl( $"URL: {url}"; } - [McpServerTool(Title = "Get Book Metadata", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), - Description("Get citation-quality metadata for the Essential C# book: title, authors, edition, C# version covered, ISBN, publisher, and website URL. Use this when generating citations or when asked which edition or C# version the book covers.")] - public string GetBookMetadata() - { - return $""" - # {BookMetadata.Title} — Book Metadata - - **Title:** {BookMetadata.Title} - **Edition:** {BookMetadata.Edition} - **C# Version:** {BookMetadata.CSharpVersion} - **Authors:** {BookMetadata.Authors} - **Publisher:** {BookMetadata.Publisher} - **ISBN-13:** {BookMetadata.Isbn13} - **Website:** {BookMetadata.SiteUrl} - """; - } - [McpServerTool(Title = "Lookup Concept", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Find all sections in the Essential C# book that cover a specific C# concept. Combines section heading search with semantic vector search (when available) to give broad coverage. Returns section slugs, chapter numbers, and direct links.")] public async Task LookupConcept( diff --git a/EssentialCSharp.Web/Views/McpSetup/Index.cshtml b/EssentialCSharp.Web/Views/McpSetup/Index.cshtml index 5fcabc01..7dfdfc7f 100644 --- a/EssentialCSharp.Web/Views/McpSetup/Index.cshtml +++ b/EssentialCSharp.Web/Views/McpSetup/Index.cshtml @@ -591,25 +591,6 @@
-
- - - get_book_metadata - Citation-quality book info - -
-

- Returns title, authors, edition, C# version covered, ISBN, publisher, and website URL. - Useful when the AI needs to cite the book or answer "which edition is this?" -

-
-
Input
-
None
-
Returns
-
Title, 9th Edition, C# 13, authors, ISBN-13, publisher, URL
-
-
-
From 19c6015a478f9228a6e43592840093f9da120c74 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 25 Apr 2026 22:04:36 -0700 Subject: [PATCH 11/14] feat: add configurable maxResults (default 5, max 10) to vector search tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AISearchService.ExecuteVectorSearch gains optional top param (default 5, clamped to 1-10) - SearchBookContent, LookupConcept, FindRelatedSections expose maxResults to MCP clients - Raises default from 3→5 for better coverage of broad C# concepts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/AISearchService.cs | 8 ++++++-- EssentialCSharp.Web/Tools/BookSearchTool.cs | 11 +++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/EssentialCSharp.Chat.Shared/Services/AISearchService.cs b/EssentialCSharp.Chat.Shared/Services/AISearchService.cs index 37f21b09..3c29d0d2 100644 --- a/EssentialCSharp.Chat.Shared/Services/AISearchService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AISearchService.cs @@ -13,9 +13,13 @@ public class AISearchService( { // TODO: Implement Hybrid Search functionality, may need to switch db providers to support full text search? + public const int DefaultSearchTop = 5; + public const int MaxSearchTop = 10; + public async Task>> ExecuteVectorSearch( - string query, string? collectionName = null, CancellationToken cancellationToken = default) + string query, string? collectionName = null, int top = DefaultSearchTop, CancellationToken cancellationToken = default) { + top = Math.Clamp(top, 1, MaxSearchTop); collectionName ??= EmbeddingService.CollectionName; VectorStoreCollection collection = vectorStore.GetCollection(collectionName); @@ -32,7 +36,7 @@ public async Task>> ExecuteVe try { var results = new List>(); - await foreach (var result in collection.SearchAsync(searchVector, options: vectorSearchOptions, top: 3, cancellationToken: cancellationToken)) + await foreach (var result in collection.SearchAsync(searchVector, options: vectorSearchOptions, top: top, cancellationToken: cancellationToken)) { results.Add(result); } diff --git a/EssentialCSharp.Web/Tools/BookSearchTool.cs b/EssentialCSharp.Web/Tools/BookSearchTool.cs index d08d1861..147b337e 100644 --- a/EssentialCSharp.Web/Tools/BookSearchTool.cs +++ b/EssentialCSharp.Web/Tools/BookSearchTool.cs @@ -32,6 +32,7 @@ public BookSearchTool(IServiceProvider serviceProvider, ISiteMappingService site 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, + [Description("Number of results to return (1–10). Default is 5. Use a higher value for broad topics or comprehensive research; lower for quick lookups.")] int maxResults = AISearchService.DefaultSearchTop, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(query)) @@ -48,7 +49,7 @@ public async Task SearchBookContent( return "Book search is not available in this environment (AI services are not configured)."; } - var results = await _SearchService.ExecuteVectorSearch(query, cancellationToken: cancellationToken); + var results = await _SearchService.ExecuteVectorSearch(query, top: maxResults, cancellationToken: cancellationToken); var sb = new StringBuilder(); int resultCount = 0; @@ -172,6 +173,7 @@ public string GetDirectContentUrl( Description("Find all sections in the Essential C# book that cover a specific C# concept. Combines section heading search with semantic vector search (when available) to give broad coverage. Returns section slugs, chapter numbers, and direct links.")] public async Task LookupConcept( [Description("The C# concept, feature, or topic to find in the book (e.g., 'LINQ', 'async/await', 'pattern matching', 'generics').")] string concept, + [Description("Number of semantic search results to return (1–10). Default is 5.")] int maxResults = AISearchService.DefaultSearchTop, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(concept)) @@ -197,7 +199,7 @@ public async Task LookupConcept( var vectorMatches = new List<(int chapter, string heading, string chunkText)>(); if (_SearchService is not null) { - var results = await _SearchService.ExecuteVectorSearch(concept, cancellationToken: cancellationToken); + var results = await _SearchService.ExecuteVectorSearch(concept, top: maxResults, cancellationToken: cancellationToken); foreach (var r in results) { vectorMatches.Add((r.Record.ChapterNumber ?? 0, r.Record.Heading ?? "", r.Record.ChunkText)); @@ -415,6 +417,7 @@ public async Task FindBookHelpForDiagnostic( Description("Find other sections in the Essential C# book that are semantically related to a given section. Uses the section heading as a search query to discover thematically connected content across the entire book. Requires AI services to be configured.")] public async Task FindRelatedSections( [Description("The section slug/key to find related content for (e.g., 'async-await'). Use GetChapterSections to get valid slugs.")] string sectionKey, + [Description("Number of related sections to return (1–10). Default is 5.")] int maxResults = AISearchService.DefaultSearchTop, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(sectionKey)) @@ -434,7 +437,7 @@ public async Task FindRelatedSections( } string query = $"{mapping.RawHeading} {mapping.ChapterTitle}"; - var results = await _SearchService.ExecuteVectorSearch(query, cancellationToken: cancellationToken); + var results = await _SearchService.ExecuteVectorSearch(query, top: maxResults, cancellationToken: cancellationToken); var sb = new StringBuilder(); sb.AppendLine(CultureInfo.InvariantCulture, $"# Sections Related to: {mapping.RawHeading}"); @@ -447,7 +450,7 @@ public async Task FindRelatedSections( { string heading = r.Record.Heading ?? ""; if (!seen.Add(heading)) continue; - if (count++ >= 3) break; + if (count++ >= maxResults) break; // Find the SiteMapping for this heading to get the link SiteMapping? relatedMapping = _SiteMappingService.SiteMappings From dbd4d80d6780fce9e3355b901fa51fa78a1d5f30 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 28 Apr 2026 13:36:58 -0700 Subject: [PATCH 12/14] PR Feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Issue 1: Replace anonymous rate-limit body with typed McpRateLimitErrorEnvelope records; use JsonSerializer.SerializeAsync instead of WriteAsync(Serialize(...)) - Issue 2: Strip prose default values from all tool Description attributes - Issue 3: Merge GetGuidelinesByTopic into GetCSharpGuidelines via optional 'topic' param; delete redundant GetGuidelinesByTopic tool - Issue 4: Make /mcp-setup page dynamic — inject IEnumerable into McpSetupController; replace 330+ lines of static HTML cards with Razor @foreach loop - Issue 5: Add listing search pattern minimum validation (>=2 alphanumeric chars or recognized C# operator) - Issues 6+7: Extract duplicated FormatGuidelineType/FormatType to shared GuidelineTypeExtensions.ToDisplayString() extension method; delete private copies Fix 3 subagent-found bugs: trimmedPattern in search, Razor HTML escaping, whitespace-only type guard - BookListingTool.cs: use trimmedPattern (not pattern) in Contains() search call - Index.cshtml: fix Razor HTML escaping of optional badge via @if block - BookGuidelinesTool.cs: use IsNullOrWhiteSpace guard for type param - BookGuidelinesTool.cs: fix description to say substring match not exact text Use MCP SDK JsonRpcError/JsonRpcErrorDetail instead of custom records Replace McpRateLimitErrorEnvelope and McpRateLimitErrorDetail private records with the SDK's built-in ModelContextProtocol.Protocol types, as suggested in PR review. --- .../Controllers/McpSetupController.cs | 38 +- .../Extensions/GuidelineTypeExtensions.cs | 13 + EssentialCSharp.Web/Program.cs | 19 +- EssentialCSharp.Web/Tools/BookContentTool.cs | 13 +- .../Tools/BookGuidelinesTool.cs | 99 ++--- EssentialCSharp.Web/Tools/BookListingTool.cs | 11 +- EssentialCSharp.Web/Tools/BookSearchTool.cs | 19 +- .../Views/McpSetup/Index.cshtml | 360 ++---------------- 8 files changed, 146 insertions(+), 426 deletions(-) create mode 100644 EssentialCSharp.Web/Extensions/GuidelineTypeExtensions.cs diff --git a/EssentialCSharp.Web/Controllers/McpSetupController.cs b/EssentialCSharp.Web/Controllers/McpSetupController.cs index e5fd6346..36efc731 100644 --- a/EssentialCSharp.Web/Controllers/McpSetupController.cs +++ b/EssentialCSharp.Web/Controllers/McpSetupController.cs @@ -1,21 +1,55 @@ using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using ModelContextProtocol.Server; +using System.Text.Json; namespace EssentialCSharp.Web.Controllers; [AllowAnonymous] public class McpSetupController : BaseController { - public McpSetupController(IRouteConfigurationService routeConfigurationService, IHttpContextAccessor httpContextAccessor) + private readonly IEnumerable _tools; + + public McpSetupController(IRouteConfigurationService routeConfigurationService, IHttpContextAccessor httpContextAccessor, IEnumerable tools) : base(routeConfigurationService, httpContextAccessor) { + _tools = tools; } [Route("/mcp-setup")] public IActionResult Index() { ViewBag.PageTitle = "MCP Setup"; - return View(); + var toolInfos = _tools + .OrderBy(t => t.ProtocolTool.Name) + .Select(t => + { + var parameters = new List(); + if (t.ProtocolTool.InputSchema.ValueKind == JsonValueKind.Object + && t.ProtocolTool.InputSchema.TryGetProperty("properties", out JsonElement props) + && props.ValueKind == JsonValueKind.Object) + { + t.ProtocolTool.InputSchema.TryGetProperty("required", out JsonElement requiredEl); + foreach (JsonProperty prop in props.EnumerateObject()) + { + string desc = prop.Value.TryGetProperty("description", out JsonElement d) ? d.GetString() ?? "" : ""; + bool required = requiredEl.ValueKind == JsonValueKind.Array + && requiredEl.EnumerateArray().Any(r => r.GetString() == prop.Name); + parameters.Add(new McpParamInfo(prop.Name, desc, required)); + } + } + return new McpToolInfo( + t.ProtocolTool.Name ?? "", + t.ProtocolTool.Title ?? t.ProtocolTool.Name ?? "", + t.ProtocolTool.Description ?? "", + parameters); + }) + .ToList(); + + return View(toolInfos); } } + +public sealed record McpToolInfo(string Name, string Title, string Description, IReadOnlyList Parameters); +public sealed record McpParamInfo(string Name, string Description, bool Required); diff --git a/EssentialCSharp.Web/Extensions/GuidelineTypeExtensions.cs b/EssentialCSharp.Web/Extensions/GuidelineTypeExtensions.cs new file mode 100644 index 00000000..51c213bf --- /dev/null +++ b/EssentialCSharp.Web/Extensions/GuidelineTypeExtensions.cs @@ -0,0 +1,13 @@ +namespace EssentialCSharp.Web.Extensions; + +public static class GuidelineTypeExtensions +{ + public static string ToDisplayString(this GuidelineType type) => type switch + { + GuidelineType.Do => "DO", + GuidelineType.Consider => "CONSIDER", + GuidelineType.Avoid => "AVOID", + GuidelineType.DoNot => "DO NOT", + _ => "NOTE" + }; +} diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 802d09c0..899d5125 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -1,4 +1,5 @@ using System.Threading.RateLimiting; +using ModelContextProtocol.Protocol; using EssentialCSharp.Chat.Common.Extensions; using EssentialCSharp.Web.Areas.Identity.Data; using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators; @@ -324,15 +325,19 @@ private static void Main(string[] args) if (context.HttpContext.Request.Path.StartsWithSegments("/mcp")) { context.HttpContext.Response.ContentType = "application/json"; - var mcpErrorResponse = new + var mcpErrorResponse = new JsonRpcError { - jsonrpc = "2.0", - error = new { code = -32000, message = "Rate limit exceeded. Please wait before sending another request." }, - id = (object?)null + JsonRpc = "2.0", + Error = new JsonRpcErrorDetail + { + Code = -32000, + Message = "Rate limit exceeded. Please wait before sending another request." + } }; - await context.HttpContext.Response.WriteAsync( - System.Text.Json.JsonSerializer.Serialize(mcpErrorResponse), - cancellationToken); + await System.Text.Json.JsonSerializer.SerializeAsync( + context.HttpContext.Response.Body, + mcpErrorResponse, + cancellationToken: cancellationToken); return; } diff --git a/EssentialCSharp.Web/Tools/BookContentTool.cs b/EssentialCSharp.Web/Tools/BookContentTool.cs index 9078d848..df2f6003 100644 --- a/EssentialCSharp.Web/Tools/BookContentTool.cs +++ b/EssentialCSharp.Web/Tools/BookContentTool.cs @@ -37,7 +37,7 @@ public BookContentTool( Description("Retrieve the prose content of a specific book section identified by its slug/key (e.g., 'hello-world', 'creating-editing-compiling-and-running-c-source-code'). Returns the section text with code examples preserved. Use GetChapterSections to discover available slugs.")] public async Task GetSectionContent( [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections to get valid slugs.")] string sectionKey, - [Description("Maximum number of characters to return (500–8000, default 4000). Long sections are truncated.")] int maxChars = 4000, + [Description("Maximum number of characters to return (500–8000). Long sections are truncated.")] int maxChars = 4000, CancellationToken cancellationToken = default) { maxChars = Math.Clamp(maxChars, 500, 8000); @@ -365,7 +365,7 @@ public string GetChapterSummary( sb.AppendLine("## Guidelines in this Chapter"); foreach (var g in guidelines) { - sb.AppendLine(CultureInfo.InvariantCulture, $"- **[{FormatGuidelineType(g.Type)}]** {g.Guideline}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"- **[{g.Type.ToDisplayString()}]** {g.Guideline}"); } } @@ -431,15 +431,6 @@ private static void ExtractNodeContent(HtmlNode node, StringBuilder sb) } } - private static string FormatGuidelineType(GuidelineType type) => type switch - { - GuidelineType.Do => "DO", - GuidelineType.Consider => "CONSIDER", - GuidelineType.Avoid => "AVOID", - GuidelineType.DoNot => "DO NOT", - _ => "NOTE" - }; - [GeneratedRegex(@"^[A-Za-z0-9_-]{1,128}$")] private static partial Regex AnchorIdRegex(); } diff --git a/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs index 8d6c8eaa..4e4f7bf3 100644 --- a/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs +++ b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Globalization; using System.Text; +using EssentialCSharp.Web.Extensions; using EssentialCSharp.Web.Services; using ModelContextProtocol.Server; @@ -17,17 +18,18 @@ public BookGuidelinesTool(IGuidelinesService guidelinesService) } [McpServerTool(Title = "Get C# Guidelines", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), - Description("Retrieve C# coding guidelines from the Essential C# book. Optionally filter by keyword, chapter number, or guideline type (do/consider/avoid/donot). The book contains guidelines covering naming conventions, error handling, LINQ, async/await, generics, and many other topics. Each guideline includes its chapter and subsection context.")] + Description("Retrieve C# coding guidelines from the Essential C# book. Filter by keyword (case-insensitive substring match), chapter number, or guideline type. Use the 'topic' parameter for relevance-ranked discovery by concept (e.g., 'exception handling', 'naming', 'async'). Each guideline includes its chapter and subsection context. Tip: use 'topic' for broad discovery; use 'keyword' for precise substring matching.")] public string GetCSharpGuidelines( - [Description("Optional keyword to filter guidelines by (searched in guideline text and subsection name).")] string? keyword = null, + [Description("Optional keyword for case-insensitive substring search in guideline text and subsection name.")] string? keyword = null, [Description("Optional chapter number to restrict results to a specific chapter.")] int? chapter = null, [Description("Optional guideline type: 'do', 'consider', 'avoid', or 'donot' (also accepts 'do not', 'dont').")] string? type = null, - [Description("Maximum number of guidelines to return (1–50, default 20).")] int maxResults = 20) + [Description("Optional topic or concept for relevance-ranked search (e.g., 'exception handling', 'naming', 'async'). Results are ordered by relevance. Use for broad discovery; use 'keyword' for substring text matching.")] string? topic = null, + [Description("Maximum number of guidelines to return (1–50).")] int maxResults = 20) { maxResults = Math.Clamp(maxResults, 1, 50); GuidelineType? typeFilter = ParseGuidelineType(type); - if (type is not null && typeFilter is null) + if (!string.IsNullOrWhiteSpace(type) && typeFilter is null) { return "Invalid guideline type. Valid values: 'do', 'consider', 'avoid', 'donot' (also accepts 'do not', 'dont')."; } @@ -46,66 +48,54 @@ public string GetCSharpGuidelines( g.SanitizedSubsection.Contains(keyword, StringComparison.OrdinalIgnoreCase) || (g.ActualSubsection?.Contains(keyword, StringComparison.OrdinalIgnoreCase) == true)); - var results = filtered.Take(maxResults).ToList(); - - if (results.Count == 0) + if (!string.IsNullOrWhiteSpace(topic)) { - return "No guidelines found matching the specified filters."; - } - - var sb = new StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"# Essential C# Guidelines ({results.Count} result{(results.Count == 1 ? "" : "s")})"); - sb.AppendLine(); + var words = topic.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var scored = filtered + .Select(g => + { + string combined = $"{g.Guideline} {g.SanitizedSubsection} {g.ActualSubsection} {g.ChapterTitle}"; + int score = words.Count(w => combined.Contains(w, StringComparison.OrdinalIgnoreCase)); + return (guideline: g, score); + }) + .Where(x => x.score > 0) + .OrderByDescending(x => x.score) + .Take(maxResults) + .ToList(); + + if (scored.Count == 0) + { + return $"No guidelines found related to '{topic}'."; + } - foreach (var g in results) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"**[{FormatType(g.Type)}]** {g.Guideline}"); - sb.AppendLine(CultureInfo.InvariantCulture, $" — Chapter {g.ChapterNumber}: {g.ChapterTitle} / {g.SanitizedSubsection}"); - sb.AppendLine(); - } + var topicSb = new StringBuilder(); + topicSb.AppendLine(CultureInfo.InvariantCulture, $"# Essential C# Guidelines — Topic: {topic} ({scored.Count} result{(scored.Count == 1 ? "" : "s")})"); + topicSb.AppendLine(); - return sb.ToString(); - } + foreach (var (g, _) in scored) + { + topicSb.AppendLine(CultureInfo.InvariantCulture, $"**[{g.Type.ToDisplayString()}]** {g.Guideline}"); + topicSb.AppendLine(CultureInfo.InvariantCulture, $" — Chapter {g.ChapterNumber}: {g.ChapterTitle} / {g.SanitizedSubsection}"); + topicSb.AppendLine(); + } - [McpServerTool(Title = "Get Guidelines By Topic", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), - Description("Search C# coding guidelines from the Essential C# book by topic or concept. More discoverable than filtering by chapter — finds all guidelines related to exceptions, naming, async, LINQ, generics, interfaces, and more. Results are ordered by relevance to the topic.")] - public string GetGuidelinesByTopic( - [Description("The topic or concept to search guidelines for (e.g., 'exception handling', 'naming', 'async', 'LINQ', 'generics', 'interface').")] string topic, - [Description("Maximum number of guidelines to return (1–30, default 15).")] int maxResults = 15) - { - if (string.IsNullOrWhiteSpace(topic)) - { - return "Topic must not be empty."; + return topicSb.ToString(); } - maxResults = Math.Clamp(maxResults, 1, 30); - - var words = topic.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var results = filtered.Take(maxResults).ToList(); - var scored = _guidelinesService.Guidelines - .Select(g => - { - string combined = $"{g.Guideline} {g.SanitizedSubsection} {g.ActualSubsection} {g.ChapterTitle}"; - int score = words.Count(w => combined.Contains(w, StringComparison.OrdinalIgnoreCase)); - return (guideline: g, score); - }) - .Where(x => x.score > 0) - .OrderByDescending(x => x.score) - .Take(maxResults) - .ToList(); - - if (scored.Count == 0) + if (results.Count == 0) { - return $"No guidelines found related to '{topic}'."; + return "No guidelines found matching the specified filters."; } var sb = new StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"# Essential C# Guidelines — Topic: {topic} ({scored.Count} result{(scored.Count == 1 ? "" : "s")})"); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Essential C# Guidelines ({results.Count} result{(results.Count == 1 ? "" : "s")})"); sb.AppendLine(); - foreach (var (g, _) in scored) + foreach (var g in results) { - sb.AppendLine(CultureInfo.InvariantCulture, $"**[{FormatType(g.Type)}]** {g.Guideline}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"**[{g.Type.ToDisplayString()}]** {g.Guideline}"); sb.AppendLine(CultureInfo.InvariantCulture, $" — Chapter {g.ChapterNumber}: {g.ChapterTitle} / {g.SanitizedSubsection}"); sb.AppendLine(); } @@ -126,13 +116,4 @@ public string GetGuidelinesByTopic( _ => null }; } - - private static string FormatType(GuidelineType type) => type switch - { - GuidelineType.Do => "DO", - GuidelineType.Consider => "CONSIDER", - GuidelineType.Avoid => "AVOID", - GuidelineType.DoNot => "DO NOT", - _ => "NOTE" - }; } diff --git a/EssentialCSharp.Web/Tools/BookListingTool.cs b/EssentialCSharp.Web/Tools/BookListingTool.cs index 19b70ee3..23413313 100644 --- a/EssentialCSharp.Web/Tools/BookListingTool.cs +++ b/EssentialCSharp.Web/Tools/BookListingTool.cs @@ -39,7 +39,7 @@ public async Task GetListingSourceCode( Description("Search all code listings in the Essential C# book for a specific code pattern, keyword, or identifier. Searches actual C# source code (not prose). Useful for finding examples of Task.WhenAll, yield return, IDisposable, pattern matching, and similar code constructs.")] public async Task SearchListingsByCode( [Description("The code pattern or keyword to search for in listing source code (case-insensitive substring match).")] string pattern, - [Description("Maximum number of matching listings to return (1–20, default 10).")] int maxResults = 10, + [Description("Maximum number of matching listings to return (1–20).")] int maxResults = 10, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(pattern)) @@ -47,6 +47,13 @@ public async Task SearchListingsByCode( return "Pattern must not be empty."; } + string trimmedPattern = pattern.Trim(); + bool isKnownOperator = trimmedPattern is "=>" or "??" or "?." or "::" or "??=" or "==" or "!=" or "<=" or ">=" or "&&" or "||"; + if (!isKnownOperator && trimmedPattern.Count(char.IsLetterOrDigit) < 2) + { + return "Pattern must contain at least two letters or digits, or be a recognized C# operator (=>, ??, ?., ::, ??=, ==, !=, <=, >=, &&, ||)."; + } + maxResults = Math.Clamp(maxResults, 1, 20); var distinctChapters = _siteMappingService.SiteMappings @@ -66,7 +73,7 @@ public async Task SearchListingsByCode( foreach (var listing in listings) { if (found >= maxResults) break; - if (listing.Content.Contains(pattern, StringComparison.OrdinalIgnoreCase)) + if (listing.Content.Contains(trimmedPattern, StringComparison.OrdinalIgnoreCase)) { string langHint = listing.FileExtension == "cs" ? "csharp" : listing.FileExtension; sb.AppendLine(CultureInfo.InvariantCulture, $"### Listing {listing.ChapterNumber}.{listing.ListingNumber}"); diff --git a/EssentialCSharp.Web/Tools/BookSearchTool.cs b/EssentialCSharp.Web/Tools/BookSearchTool.cs index 147b337e..d53f6506 100644 --- a/EssentialCSharp.Web/Tools/BookSearchTool.cs +++ b/EssentialCSharp.Web/Tools/BookSearchTool.cs @@ -32,7 +32,7 @@ public BookSearchTool(IServiceProvider serviceProvider, ISiteMappingService site 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, - [Description("Number of results to return (1–10). Default is 5. Use a higher value for broad topics or comprehensive research; lower for quick lookups.")] int maxResults = AISearchService.DefaultSearchTop, + [Description("Number of results to return (1–10). Use a higher value for broad topics or comprehensive research; lower for quick lookups.")] int maxResults = AISearchService.DefaultSearchTop, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(query)) @@ -173,7 +173,7 @@ public string GetDirectContentUrl( Description("Find all sections in the Essential C# book that cover a specific C# concept. Combines section heading search with semantic vector search (when available) to give broad coverage. Returns section slugs, chapter numbers, and direct links.")] public async Task LookupConcept( [Description("The C# concept, feature, or topic to find in the book (e.g., 'LINQ', 'async/await', 'pattern matching', 'generics').")] string concept, - [Description("Number of semantic search results to return (1–10). Default is 5.")] int maxResults = AISearchService.DefaultSearchTop, + [Description("Number of semantic search results to return (1–10).")] int maxResults = AISearchService.DefaultSearchTop, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(concept)) @@ -404,7 +404,7 @@ public async Task FindBookHelpForDiagnostic( sb.AppendLine("## Related Guidelines"); foreach (var g in guidelineMatches) { - sb.AppendLine(CultureInfo.InvariantCulture, $"**[{FormatGuidelineType(g.Type)}]** {g.Guideline}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"**[{g.Type.ToDisplayString()}]** {g.Guideline}"); sb.AppendLine(CultureInfo.InvariantCulture, $" — Chapter {g.ChapterNumber}: {g.ChapterTitle} / {g.SanitizedSubsection}"); sb.AppendLine(); } @@ -417,7 +417,7 @@ public async Task FindBookHelpForDiagnostic( Description("Find other sections in the Essential C# book that are semantically related to a given section. Uses the section heading as a search query to discover thematically connected content across the entire book. Requires AI services to be configured.")] public async Task FindRelatedSections( [Description("The section slug/key to find related content for (e.g., 'async-await'). Use GetChapterSections to get valid slugs.")] string sectionKey, - [Description("Number of related sections to return (1–10). Default is 5.")] int maxResults = AISearchService.DefaultSearchTop, + [Description("Number of related sections to return (1–10).")] int maxResults = AISearchService.DefaultSearchTop, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(sectionKey)) @@ -474,16 +474,7 @@ public async Task FindRelatedSections( return sb.ToString(); } - private static string FormatGuidelineType(GuidelineType type) => type switch - { - GuidelineType.Do => "DO", - GuidelineType.Consider => "CONSIDER", - GuidelineType.Avoid => "AVOID", - GuidelineType.DoNot => "DO NOT", - _ => "NOTE" - }; - - private static readonly Dictionary DiagnosticMap = new(StringComparer.OrdinalIgnoreCase) + private static readonly Dictionary DiagnosticMap= new(StringComparer.OrdinalIgnoreCase) { // Nullable reference types ["CS8600"] = "nullable reference types", diff --git a/EssentialCSharp.Web/Views/McpSetup/Index.cshtml b/EssentialCSharp.Web/Views/McpSetup/Index.cshtml index 7dfdfc7f..f2472b9e 100644 --- a/EssentialCSharp.Web/Views/McpSetup/Index.cshtml +++ b/EssentialCSharp.Web/Views/McpSetup/Index.cshtml @@ -1,5 +1,7 @@ @using Microsoft.AspNetCore.Identity @using EssentialCSharp.Web.Areas.Identity.Data +@using EssentialCSharp.Web.Controllers +@model IReadOnlyList @inject SignInManager SignInManager @{ ViewData["Title"] = "AI Tools Setup"; @@ -259,338 +261,34 @@ -

Once connected, your AI client has access to 17 tools organized into four groups:

- -
Search & Discovery
- -
- - - search_book_content - Semantic search over book chapters - -
-

- Semantic vector search over all Essential C# book chapters. Returns the most relevant text - chunks with chapter number, section heading, and relevance score. Use for questions like - "How does async/await work?" or "What is pattern matching?" -

-
-
Input
-
query — natural-language C# concept or question
-
Returns
-
Ranked text chunks with chapter, section, and relevance score
-
-
-
- -
- - - lookup_concept - Find all sections covering a concept - -
-

- Combines section heading text-match with semantic search to find every place a C# concept - appears in the book. Good for topics like "LINQ", "generics", or "async/await". -

-
-
Input
-
concept — C# feature or topic name
-
Returns
-
Section headings with chapter numbers and deep links
-
-
-
- -
- - - check_topic_coverage - Assess how thoroughly a topic is covered - -
-

- Tells the AI how well the book covers a given topic — "Comprehensive" (dedicated sections), - "Mentioned" (in prose only), or "Not covered". Helps AI calibrate its confidence before citing the book. -

-
-
Input
-
topic — C# concept to check (e.g., "source generators", "records")
-
Returns
-
Coverage assessment with relevant section links
-
-
-
- -
- - - find_book_help_for_diagnostic - Map compiler errors to book sections - -
-

- Given a C# compiler error code (e.g. CS8600) or a plain description (e.g. "null reference exception"), - returns the most relevant book sections and guidelines. Especially useful when debugging C# code with AI assistance. -

-
-
Input
-
diagnostic — a CS error code or plain error description
-
Returns
-
Relevant sections, prose excerpts, and related coding guidelines
-
-
-
- -
- - - find_related_sections - Semantically related sections - -
-

- Given a section slug, finds other book sections with similar themes using vector search. - Great for "what else should I read after this?" recommendations. -

-
-
Input
-
sectionKey — section slug (use get_chapter_sections to find valid values)
-
Returns
-
Up to 3 related sections with headings, links, and content previews
-
-
-
- -
Navigation & Content
- -
- - - get_chapter_list - Full table of contents - -
-

- Returns the full table of contents — all chapters and sections with navigation links. - A good starting point for any AI session with this server. -

-
-
Input
-
None
-
Returns
-
Hierarchical chapter and section list with links
-
-
-
- -
- - - get_chapter_sections - All sections in one chapter - -
-

- Lists every section and subsection in a given chapter with headings, slugs, and anchor links. - Use the returned slugs with content tools like get_section_content or get_navigation_context. -

-
-
Input
-
chapter — chapter number (e.g., 10)
-
Returns
-
Indented section list with slugs and deep links
-
-
-
- -
- - - get_section_content - Read a section's prose text - -
-

- Extracts and returns the readable text of a specific section from the HTML chapter pages, - with code examples preserved. Use this to quote or summarize specific book passages. -

-
-
Input
-
sectionKey, optional maxChars (500–8000, default 4000)
-
Returns
-
Section heading, chapter context, and formatted prose text
-
-
-
- -
- - - get_navigation_context - Breadcrumbs, prev/next, siblings - -
-

- Returns the full navigation context for a section: breadcrumb path, previous section, - next section, parent, and sibling list. Powers AI "what should I read next?" flows. -

-
-
Input
-
sectionKey — section slug
-
Returns
-
Breadcrumbs, previous, next, parent, and sibling section links
-
-
-
- -
- - - get_chapter_summary - Chapter overview with guidelines - -
-

- Returns a chapter overview: all sections in reading order plus the most relevant coding - guidelines for that chapter. Ideal for "give me an overview of chapter N" prompts. -

-
-
Input
-
chapter — chapter number
-
Returns
-
Chapter title, section list with links, and relevant guidelines
-
-
-
- -
- - - get_direct_content_url - Deep-link URL for any section - -
-

- Returns the canonical URL for any section or subsection so AI can include clickable - references in its answers (e.g. https://essentialcsharp.com/async-await#awaitExpression). -

-
-
Input
-
sectionKey — section slug
-
Returns
-
Section heading, chapter info, and full URL
-
-
-
- -
Code Listings
- -
- - - get_listing_source_code - Source code for a numbered listing - -
-

- Retrieves the complete source code for any numbered listing in the book - (e.g. "show me Listing 10.3"). Returns the code with its language hint. -

-
-
Input
-
chapter, listing — e.g., chapter=10, listing=3 for Listing 10.3
-
Returns
-
Formatted code block with language hint
-
-
-
- -
- - - get_listing_with_context - Listing + book explanation - -
-

- Gets a listing's source code together with the semantically nearest prose excerpts that - explain it. Useful for "explain this listing" prompts. -

-
-
Input
-
chapter, listing
-
Returns
-
Source code + explanatory prose from nearby sections
-
-
-
- -
- - - search_listings_by_code - Search source files for a code pattern - -
-

- Searches all listing source files for a code pattern, identifier, or construct - (e.g. "Task.WhenAll", "yield return", "IDisposable"). Searches actual C#, not prose. -

-
-
Input
-
pattern — case-insensitive substring; optional maxResults (1–20, default 10)
-
Returns
-
Matching source listings with chapter.listing numbering
-
-
-
- -
Coding Guidelines
- -
- - - get_csharp_guidelines - 241 C# coding guidelines, filterable - -
-

- Access the book's 241 C# coding guidelines — DO, CONSIDER, AVOID, and DO NOT rules - covering naming conventions, error handling, LINQ, async/await, generics, and more. - Filter by keyword, chapter, or rule type. -

-
-
Input
-
Optional: keyword, chapter, type (do/consider/avoid/donot), maxResults
-
Returns
-
Guidelines with type label, chapter, and subsection context
-
-
-
- -
- - - get_guidelines_by_topic - Guidelines ranked by topic relevance - -
-

- Finds guidelines related to a concept (e.g. "exception handling", "naming", "interfaces") - and ranks them by how closely they match. More discoverable than filtering by chapter number. -

-
-
Input
-
topic — concept or keyword; optional maxResults (1–30, default 15)
-
Returns
-
Guidelines ranked by relevance with type labels and chapter context
-
-
-
+

Once connected, your AI client has access to @Model.Count tool@(Model.Count == 1 ? "" : "s"):

+ @foreach (var tool in Model) + { +
+ + + @tool.Name + @tool.Title + +
+

@tool.Description

+ @if (tool.Parameters.Count > 0) + { +
+ @foreach (var param in tool.Parameters) + { +
+ @param.Name + @if (!param.Required) { (optional) } +
+
@param.Description
+ } +
+ } +
+
+ } From cfba67704bbf895a4311f85b958bac91b597b5c5 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 28 Apr 2026 17:26:24 -0700 Subject: [PATCH 13/14] Structured output --- .../Services/AIChatService.cs | 4 +- .../Services/McpToolResultFormatter.cs | 24 ++ .../McpToolResultFormatterTests.cs | 49 +++ .../McpToolContractTests.cs | 337 ++++++++++++++++ .../Models/McpContentTextResults.cs | 220 +++++++++++ .../Models/McpGuidelineTextResults.cs | 42 ++ .../Models/McpListingTextResults.cs | 88 +++++ .../Models/McpSearchTextResults.cs | 243 ++++++++++++ .../Models/McpToolResultFactory.cs | 40 ++ EssentialCSharp.Web/Models/McpToolResults.cs | 72 ++++ EssentialCSharp.Web/Program.cs | 5 +- .../Services/BookToolQueryService.cs | 246 ++++++++++++ .../Services/IBookToolQueryService.cs | 12 + EssentialCSharp.Web/Services/SiteSettings.cs | 8 + EssentialCSharp.Web/Tools/BookContentTool.cs | 331 ++-------------- .../Tools/BookGuidelinesTool.cs | 43 +- EssentialCSharp.Web/Tools/BookListingTool.cs | 65 +-- EssentialCSharp.Web/Tools/BookSearchTool.cs | 372 ++++++------------ 18 files changed, 1597 insertions(+), 604 deletions(-) create mode 100644 EssentialCSharp.Chat.Shared/Services/McpToolResultFormatter.cs create mode 100644 EssentialCSharp.Chat.Tests/McpToolResultFormatterTests.cs create mode 100644 EssentialCSharp.Web.Tests/McpToolContractTests.cs create mode 100644 EssentialCSharp.Web/Models/McpContentTextResults.cs create mode 100644 EssentialCSharp.Web/Models/McpGuidelineTextResults.cs create mode 100644 EssentialCSharp.Web/Models/McpListingTextResults.cs create mode 100644 EssentialCSharp.Web/Models/McpSearchTextResults.cs create mode 100644 EssentialCSharp.Web/Models/McpToolResultFactory.cs create mode 100644 EssentialCSharp.Web/Models/McpToolResults.cs create mode 100644 EssentialCSharp.Web/Services/BookToolQueryService.cs create mode 100644 EssentialCSharp.Web/Services/IBookToolQueryService.cs create mode 100644 EssentialCSharp.Web/Services/SiteSettings.cs diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index 9c3298dd..984cb455 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -239,7 +239,7 @@ private async Task EnrichPromptWithContext(string prompt, bool enableCon var inputItems = new List { functionCallItem, // The original function call - new FunctionCallOutputResponseItem(functionCallItem.CallId, string.Join("", toolResult.Content.Where(x => x.Type == "text").OfType().Select(x => x.Text))) + new FunctionCallOutputResponseItem(functionCallItem.CallId, McpToolResultFormatter.GetModelInput(toolResult)) }; #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. @@ -375,7 +375,7 @@ private static async Task CreateResponseOptionsAsync( responseItems.Add(functionCallItem); responseItems.Add(new FunctionCallOutputResponseItem( functionCallItem.CallId, - string.Join("", toolResult.Content.Where(x => x.Type == "text").OfType().Select(x => x.Text)))); + McpToolResultFormatter.GetModelInput(toolResult))); } continue; } diff --git a/EssentialCSharp.Chat.Shared/Services/McpToolResultFormatter.cs b/EssentialCSharp.Chat.Shared/Services/McpToolResultFormatter.cs new file mode 100644 index 00000000..be9d83ff --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/McpToolResultFormatter.cs @@ -0,0 +1,24 @@ +using ModelContextProtocol.Protocol; + +namespace EssentialCSharp.Chat.Common.Services; + +public static class McpToolResultFormatter +{ + public static string GetModelInput(CallToolResult toolResult) + { + if (toolResult.StructuredContent is { } structuredContent) + { + return structuredContent.GetRawText(); + } + + return GetPrimaryTextContent(toolResult.Content); + } + + public static string GetPrimaryTextContent(IEnumerable contentBlocks) => + contentBlocks + .Where(x => x.Type == "text") + .OfType() + .Select(x => x.Text) + .FirstOrDefault(text => !string.IsNullOrWhiteSpace(text)) + ?? string.Empty; +} diff --git a/EssentialCSharp.Chat.Tests/McpToolResultFormatterTests.cs b/EssentialCSharp.Chat.Tests/McpToolResultFormatterTests.cs new file mode 100644 index 00000000..5d607817 --- /dev/null +++ b/EssentialCSharp.Chat.Tests/McpToolResultFormatterTests.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using EssentialCSharp.Chat.Common.Services; +using ModelContextProtocol.Protocol; + +namespace EssentialCSharp.Chat.Tests; + +public class McpToolResultFormatterTests +{ + [Test] + public async Task GetModelInput_PrefersStructuredContent_WhenAvailable() + { + JsonElement structuredContent = JsonSerializer.SerializeToElement(new + { + diagnostic = "CS8600", + relevantSections = Array.Empty() + }); + + CallToolResult toolResult = new() + { + Content = + [ + new TextContentBlock { Text = "# Book Help for: CS8600" }, + new TextContentBlock { Text = "{\"diagnostic\":\"CS8600\"}" } + ], + StructuredContent = structuredContent + }; + + string modelInput = McpToolResultFormatter.GetModelInput(toolResult); + + await Assert.That(modelInput).IsEqualTo(structuredContent.GetRawText()); + } + + [Test] + public async Task GetModelInput_FallsBackToFirstTextBlock_WhenStructuredContentIsMissing() + { + CallToolResult toolResult = new() + { + Content = + [ + new TextContentBlock { Text = "# Readable content" }, + new TextContentBlock { Text = "{\"json\":\"fallback\"}" } + ] + }; + + string modelInput = McpToolResultFormatter.GetModelInput(toolResult); + + await Assert.That(modelInput).IsEqualTo("# Readable content"); + } +} diff --git a/EssentialCSharp.Web.Tests/McpToolContractTests.cs b/EssentialCSharp.Web.Tests/McpToolContractTests.cs new file mode 100644 index 00000000..463cdf95 --- /dev/null +++ b/EssentialCSharp.Web.Tests/McpToolContractTests.cs @@ -0,0 +1,337 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Tests; + +[NotInParallel("McpTests")] +[ClassDataSource(Shared = SharedType.PerClass)] +public class McpToolContractTests(WebApplicationFactory factory) +{ + [Test] + public async Task McpToolsList_StructuredAndHybridTools_AdvertiseOutputSchema() + { + (HttpClient client, string rawToken, string? sessionId) = await CreateAuthenticatedSessionAsync(); + + using HttpResponseMessage response = await SendRpcAsync( + client, + rawToken, + sessionId, + """ + {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} + """); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + using JsonDocument document = JsonDocument.Parse(await ReadMcpPayloadAsync(response)); + JsonElement tools = document.RootElement.GetProperty("result").GetProperty("tools"); + + await Assert.That(GetTool(tools, "get_chapter_list").TryGetProperty("outputSchema", out _)).IsTrue(); + await Assert.That(GetTool(tools, "get_chapter_sections").TryGetProperty("outputSchema", out _)).IsTrue(); + await Assert.That(GetTool(tools, "get_direct_content_url").TryGetProperty("outputSchema", out _)).IsTrue(); + await Assert.That(GetTool(tools, "get_navigation_context").TryGetProperty("outputSchema", out _)).IsTrue(); + await Assert.That(GetTool(tools, "get_chapter_summary").TryGetProperty("outputSchema", out _)).IsTrue(); + await Assert.That(GetTool(tools, "search_listings_by_code").TryGetProperty("outputSchema", out _)).IsTrue(); + await Assert.That(GetTool(tools, "find_book_help_for_diagnostic").TryGetProperty("outputSchema", out _)).IsTrue(); + + await Assert.That(GetTool(tools, "get_section_content").TryGetProperty("outputSchema", out _)).IsFalse(); + await Assert.That(GetTool(tools, "get_listing_source_code").TryGetProperty("outputSchema", out _)).IsFalse(); + } + + [Test] + public async Task McpCall_GetChapterSections_ReturnsStructuredContentAndJsonTextFallback() + { + (HttpClient client, string rawToken, string? sessionId) = await CreateAuthenticatedSessionAsync(); + + using HttpResponseMessage response = await SendRpcAsync( + client, + rawToken, + sessionId, + """ + {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_chapter_sections","arguments":{"chapter":1}}} + """); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + using JsonDocument document = JsonDocument.Parse(await ReadMcpPayloadAsync(response)); + JsonElement result = document.RootElement.GetProperty("result"); + JsonElement structuredContent = result.GetProperty("structuredContent"); + + await Assert.That(structuredContent.GetProperty("chapterNumber").GetInt32()).IsEqualTo(1); + await Assert.That(structuredContent.GetProperty("chapterTitle").GetString()).IsNotNull(); + await Assert.That(structuredContent.GetProperty("sections").GetArrayLength()).IsGreaterThan(0); + + JsonElement firstSection = structuredContent.GetProperty("sections")[0]; + await Assert.That(firstSection.GetProperty("key").GetString()).IsNotNull(); + await Assert.That(firstSection.GetProperty("href").GetString()).StartsWith("/"); + await Assert.That(firstSection.GetProperty("url").GetString()).StartsWith(GetConfiguredBaseUrl()); + + string text = result.GetProperty("content")[0].GetProperty("text").GetString() + ?? throw new InvalidOperationException("Expected JSON text fallback for structured MCP tool result."); + using JsonDocument textDocument = JsonDocument.Parse(text); + await Assert.That(textDocument.RootElement.GetProperty("chapterNumber").GetInt32()).IsEqualTo(1); + await Assert.That(textDocument.RootElement.GetProperty("sections").GetArrayLength()).IsGreaterThan(0); + } + + [Test] + public async Task McpCall_GetSectionContent_IncludesTableRowsInTextOutput() + { + (HttpClient client, string rawToken, string? sessionId) = await CreateAuthenticatedSessionAsync(); + + using HttpResponseMessage response = await SendRpcAsync( + client, + rawToken, + sessionId, + """ + {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_section_content","arguments":{"sectionKey":"c-keywords"}}} + """); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + using JsonDocument document = JsonDocument.Parse(await ReadMcpPayloadAsync(response)); + JsonElement result = document.RootElement.GetProperty("result"); + string text = result.GetProperty("content")[0].GetProperty("text").GetString() + ?? throw new InvalidOperationException("Expected text content for get_section_content."); + + await Assert.That(text).Contains("Table 1.1: C# Keywords"); + await Assert.That(text).Contains("| abstract | add*(1) | alias*(2) | and* |"); + } + + [Test] + public async Task McpCall_SearchListingsByCode_ReturnsReadableTextAndStructuredContent() + { + (HttpClient client, string rawToken, string? sessionId) = await CreateAuthenticatedSessionAsync(); + + using HttpResponseMessage response = await SendRpcAsync( + client, + rawToken, + sessionId, + """ + {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"search_listings_by_code","arguments":{"pattern":"Main("}}} + """); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + using JsonDocument document = JsonDocument.Parse(await ReadMcpPayloadAsync(response)); + JsonElement result = document.RootElement.GetProperty("result"); + JsonElement structuredContent = result.GetProperty("structuredContent"); + + await Assert.That(structuredContent.GetProperty("pattern").GetString()).IsEqualTo("Main("); + JsonElement matches = structuredContent.GetProperty("matches"); + await Assert.That(matches.GetArrayLength()).IsGreaterThan(0); + await Assert.That(matches[0].GetProperty("chapterNumber").GetInt32()).IsGreaterThan(0); + await Assert.That(matches[0].GetProperty("content").GetString()).IsNotNull(); + + JsonElement content = result.GetProperty("content"); + await Assert.That(content.GetArrayLength()).IsEqualTo(2); + + string readableText = content[0].GetProperty("text").GetString() + ?? throw new InvalidOperationException("Expected readable text content for search_listings_by_code."); + await Assert.That(readableText).Contains("Listings Containing 'Main('"); + + string jsonText = content[1].GetProperty("text").GetString() + ?? throw new InvalidOperationException("Expected JSON fallback text for search_listings_by_code."); + using JsonDocument jsonTextDocument = JsonDocument.Parse(jsonText); + await Assert.That(jsonTextDocument.RootElement.GetProperty("matches").GetArrayLength()).IsGreaterThan(0); + } + + [Test] + public async Task McpCall_FindBookHelpForDiagnostic_ReturnsReadableTextAndStructuredContent() + { + (HttpClient client, string rawToken, string? sessionId) = await CreateAuthenticatedSessionAsync(); + + using HttpResponseMessage response = await SendRpcAsync( + client, + rawToken, + sessionId, + """ + {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"find_book_help_for_diagnostic","arguments":{"diagnostic":"exceptions"}}} + """); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + using JsonDocument document = JsonDocument.Parse(await ReadMcpPayloadAsync(response)); + JsonElement result = document.RootElement.GetProperty("result"); + JsonElement structuredContent = result.GetProperty("structuredContent"); + + await Assert.That(structuredContent.GetProperty("diagnostic").GetString()).IsEqualTo("exceptions"); + + int totalMatches = + structuredContent.GetProperty("relevantSections").GetArrayLength() + + structuredContent.GetProperty("relevantBookContent").GetArrayLength() + + structuredContent.GetProperty("relatedGuidelines").GetArrayLength(); + await Assert.That(totalMatches).IsGreaterThan(0); + + JsonElement content = result.GetProperty("content"); + await Assert.That(content.GetArrayLength()).IsEqualTo(2); + + string readableText = content[0].GetProperty("text").GetString() + ?? throw new InvalidOperationException("Expected readable text content for find_book_help_for_diagnostic."); + await Assert.That(readableText).Contains("# Book Help for: exceptions"); + + string jsonText = content[1].GetProperty("text").GetString() + ?? throw new InvalidOperationException("Expected JSON fallback text for find_book_help_for_diagnostic."); + using JsonDocument jsonTextDocument = JsonDocument.Parse(jsonText); + await Assert.That(jsonTextDocument.RootElement.GetProperty("diagnostic").GetString()).IsEqualTo("exceptions"); + } + + [Test] + public async Task McpCall_GetChapterSections_WithInvalidChapter_ReturnsMcpError() + { + (HttpClient client, string rawToken, string? sessionId) = await CreateAuthenticatedSessionAsync(); + + using HttpResponseMessage response = await SendRpcAsync( + client, + rawToken, + sessionId, + """ + {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"get_chapter_sections","arguments":{"chapter":999}}} + """); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + string payload = await ReadMcpPayloadAsync(response); + await Assert.That(payload).Contains("Chapter 999 not found. Use GetChapterList to see all available chapters."); + } + + private async Task<(HttpClient Client, string RawToken, string? SessionId)> CreateAuthenticatedSessionAsync() + { + string rawToken = await CreateTokenAsync(); + HttpClient client = factory.CreateClient(); + + using var initRequest = CreateMcpInitializeRequest("/mcp"); + initRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); + + using HttpResponseMessage initResponse = await client.SendAsync(initRequest); + await Assert.That(initResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + string? sessionId = null; + if (initResponse.Headers.TryGetValues("Mcp-Session-Id", out IEnumerable? sessionIdValues)) + { + sessionId = sessionIdValues.First(); + } + + return (client, rawToken, sessionId); + } + + private async Task CreateTokenAsync() + { + string testUserId = Guid.NewGuid().ToString(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Users.Add(new EssentialCSharpWebUser + { + Id = testUserId, + UserName = $"mcp-contract-{testUserId[..8]}", + NormalizedUserName = $"MCP-CONTRACT-{testUserId[..8].ToUpperInvariant()}", + Email = $"mcp-contract-{testUserId[..8]}@example.com", + NormalizedEmail = $"MCP-CONTRACT-{testUserId[..8].ToUpperInvariant()}@EXAMPLE.COM", + SecurityStamp = Guid.NewGuid().ToString(), + }); + await db.SaveChangesAsync(); + + var tokenService = scope.ServiceProvider.GetRequiredService(); + (string rawToken, _) = await tokenService.CreateTokenAsync(testUserId, "mcp-contract-test"); + return rawToken; + } + + private string GetConfiguredBaseUrl() + { + string baseUrl = factory.Services.GetRequiredService>().Value.BaseUrl; + return baseUrl.TrimEnd('/') + "/"; + } + + private static async Task SendRpcAsync( + HttpClient client, + string rawToken, + string? sessionId, + string payload) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.Accept.ParseAdd("text/event-stream"); + if (sessionId is not null) + { + request.Headers.Add("Mcp-Session-Id", sessionId); + } + + return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + } + + private static async Task ReadMcpPayloadAsync(HttpResponseMessage response) + { + using Stream stream = await response.Content.ReadAsStreamAsync(); + using StreamReader reader = new(stream); + + List lines = []; + while (await reader.ReadLineAsync() is { } line) + { + if (!string.IsNullOrWhiteSpace(line)) + { + lines.Add(line.Trim()); + } + } + + List dataPayloads = lines + .Where(line => line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + .Select(line => line["data:".Length..].Trim()) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + + if (dataPayloads.Count > 0) + { + return dataPayloads.LastOrDefault(line => line.StartsWith('{')) + ?? dataPayloads[^1]; + } + + return string.Join(Environment.NewLine, lines); + } + + private static JsonElement GetTool(JsonElement tools, string toolName) + { + foreach (JsonElement tool in tools.EnumerateArray()) + { + if (string.Equals(tool.GetProperty("name").GetString(), toolName, StringComparison.Ordinal)) + { + return tool; + } + } + + throw new InvalidOperationException($"Could not find MCP tool '{toolName}' in tools/list response."); + } + + 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") + }; + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.Accept.ParseAdd("text/event-stream"); + return request; + } +} diff --git a/EssentialCSharp.Web/Models/McpContentTextResults.cs b/EssentialCSharp.Web/Models/McpContentTextResults.cs new file mode 100644 index 00000000..7888fe1e --- /dev/null +++ b/EssentialCSharp.Web/Models/McpContentTextResults.cs @@ -0,0 +1,220 @@ +using System.Globalization; +using System.Text; +using HtmlAgilityPack; + +namespace EssentialCSharp.Web.Models; + +public sealed record SectionContentTextResult( + string Heading, + int ChapterNumber, + string ChapterTitle, + string Body) +{ + public string ToMcpString() + { + StringBuilder sb = new(); + sb.AppendLine(CultureInfo.InvariantCulture, $"## {Heading}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"Chapter {ChapterNumber}: {ChapterTitle}"); + sb.AppendLine(); + sb.Append(Body); + return sb.ToString(); + } +} + +public sealed record SectionContentExtractionResult( + SectionContentTextResult? Content, + string? ErrorMessage) +{ + public static SectionContentExtractionResult FromHtml(SiteMapping mapping, string htmlContent, int maxChars) + { + HtmlDocument doc = new(); + doc.LoadHtml(htmlContent); + + HtmlNode? sectionNode = doc.DocumentNode.SelectSingleNode( + $"//div[@id='{mapping.AnchorId}' and contains(@class,'section-heading')]"); + + if (sectionNode is null) + { + return new SectionContentExtractionResult(null, $"Section heading element not found for anchor '{mapping.AnchorId}'."); + } + + HtmlNode? parent = sectionNode.ParentNode; + if (parent is null) + { + return new SectionContentExtractionResult(null, $"Section heading element not found for anchor '{mapping.AnchorId}'."); + } + + StringBuilder body = new(); + bool collecting = false; + foreach (HtmlNode child in parent.ChildNodes) + { + if (!collecting) + { + if (child == sectionNode) + { + collecting = true; + } + + continue; + } + + if (child.Name == "div" && + child.HasAttributes && + !string.IsNullOrEmpty(child.GetAttributeValue("id", "")) && + child.GetAttributeValue("class", "").Contains("section-heading")) + { + break; + } + + ExtractNodeContent(child, body); + + if (body.Length >= maxChars) + { + body.Append("\n\n[Content truncated — use a larger maxChars value to see more.]"); + break; + } + } + + if (body.Length == 0) + { + return new SectionContentExtractionResult(null, $"No content found after section heading '{mapping.RawHeading}'."); + } + + return new SectionContentExtractionResult( + new SectionContentTextResult(mapping.RawHeading, mapping.ChapterNumber, mapping.ChapterTitle, body.ToString()), + null); + } + + private static void ExtractNodeContent(HtmlNode node, StringBuilder sb) + { + if (node.NodeType == HtmlNodeType.Text) + { + string text = HtmlEntity.DeEntitize(node.InnerText).Trim(); + if (!string.IsNullOrEmpty(text)) + { + sb.AppendLine(text); + } + + return; + } + + string nodeClass = node.GetAttributeValue("class", ""); + + if (node.Name == "table") + { + AppendTable(node, sb); + return; + } + + if (node.Name is not ("div" or "p" or "ul" or "ol" or "li" or "span")) + { + return; + } + + if (nodeClass.Contains("table-heading")) + { + string text = CollapseWhitespace(node.InnerText); + if (!string.IsNullOrEmpty(text)) + { + sb.AppendLine(text); + sb.AppendLine(); + } + + return; + } + + if (nodeClass.Contains("code-block-section")) + { + HtmlNode? headingNode = node.SelectSingleNode(".//div[contains(@class,'code-block-heading')]"); + if (headingNode is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"\n**{HtmlEntity.DeEntitize(headingNode.InnerText).Trim()}**"); + } + + sb.AppendLine("```csharp"); + HtmlNodeCollection? codeLines = node.SelectNodes(".//div[contains(@class,'code-line')]"); + if (codeLines is not null) + { + foreach (HtmlNode lineClone in codeLines.Select(line => line.CloneNode(deep: true))) + { + HtmlNode? lineNumberSpan = lineClone.SelectSingleNode(".//span[contains(@class,'code-line-number')]"); + lineNumberSpan?.Remove(); + sb.AppendLine(HtmlEntity.DeEntitize(lineClone.InnerText)); + } + } + + sb.AppendLine("```"); + return; + } + + if (node.Name is "p" || nodeClass.Contains("paragraph")) + { + string text = HtmlEntity.DeEntitize(node.InnerText).Trim(); + if (!string.IsNullOrEmpty(text)) + { + sb.AppendLine(text); + sb.AppendLine(); + } + + return; + } + + foreach (HtmlNode child in node.ChildNodes) + { + ExtractNodeContent(child, sb); + } + } + + private static void AppendTable(HtmlNode tableNode, StringBuilder sb) + { + HtmlNodeCollection? rows = tableNode.SelectNodes(".//tr"); + if (rows is null) + { + return; + } + + bool wroteAnyRow = false; + bool wroteHeaderSeparator = false; + foreach (HtmlNode row in rows) + { + HtmlNodeCollection? cells = row.SelectNodes("./th|./td"); + if (cells is null || cells.Count == 0) + { + continue; + } + + List values = cells + .Select(cell => CollapseWhitespace(cell.InnerText).Replace("|", "\\|", StringComparison.Ordinal)) + .ToList(); + + if (values.All(string.IsNullOrEmpty)) + { + continue; + } + + sb.Append("| "); + sb.Append(string.Join(" | ", values)); + sb.AppendLine(" |"); + wroteAnyRow = true; + + bool isHeaderRow = row.GetAttributeValue("class", "").Contains("header-row", StringComparison.Ordinal) + || row.SelectNodes("./th") is { Count: > 0 }; + + if (!wroteHeaderSeparator && isHeaderRow) + { + sb.Append("| "); + sb.Append(string.Join(" | ", Enumerable.Repeat("---", values.Count))); + sb.AppendLine(" |"); + wroteHeaderSeparator = true; + } + } + + if (wroteAnyRow) + { + sb.AppendLine(); + } + } + + private static string CollapseWhitespace(string text) => + string.Join(" ", HtmlEntity.DeEntitize(text).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); +} diff --git a/EssentialCSharp.Web/Models/McpGuidelineTextResults.cs b/EssentialCSharp.Web/Models/McpGuidelineTextResults.cs new file mode 100644 index 00000000..b1582c8f --- /dev/null +++ b/EssentialCSharp.Web/Models/McpGuidelineTextResults.cs @@ -0,0 +1,42 @@ +using System.Globalization; +using System.Text; + +namespace EssentialCSharp.Web.Models; + +public sealed record TextGuidelineResult( + string Type, + string Guideline, + int ChapterNumber, + string ChapterTitle, + string Subsection) +{ + internal void AppendTo(StringBuilder sb) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"**[{Type}]** {Guideline}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" — Chapter {ChapterNumber}: {ChapterTitle} / {Subsection}"); + sb.AppendLine(); + } +} + +public sealed record GuidelinesTextResult( + string? Topic, + IReadOnlyList Guidelines) +{ + public string ToMcpString() + { + StringBuilder sb = new(); + string title = Topic is null + ? $"# Essential C# Guidelines ({Guidelines.Count} result{(Guidelines.Count == 1 ? "" : "s")})" + : $"# Essential C# Guidelines — Topic: {Topic} ({Guidelines.Count} result{(Guidelines.Count == 1 ? "" : "s")})"; + + sb.AppendLine(title); + sb.AppendLine(); + + foreach (TextGuidelineResult guideline in Guidelines) + { + guideline.AppendTo(sb); + } + + return sb.ToString(); + } +} diff --git a/EssentialCSharp.Web/Models/McpListingTextResults.cs b/EssentialCSharp.Web/Models/McpListingTextResults.cs new file mode 100644 index 00000000..a748be83 --- /dev/null +++ b/EssentialCSharp.Web/Models/McpListingTextResults.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using System.Text; + +namespace EssentialCSharp.Web.Models; + +public sealed record ListingSourceCodeTextResult( + int ChapterNumber, + int ListingNumber, + string LanguageHint, + string Content) +{ + public string ToMcpString() + { + StringBuilder sb = new(); + AppendTo(sb, "##"); + return sb.ToString(); + } + + internal void AppendTo(StringBuilder sb, string headingPrefix) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"{headingPrefix} Listing {ChapterNumber}.{ListingNumber}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"```{LanguageHint}"); + sb.AppendLine(Content); + sb.AppendLine("```"); + } +} + +public sealed record RelatedBookExplanationTextResult( + string? Heading, + int? ChapterNumber, + string ChunkText) +{ + internal void AppendTo(StringBuilder sb) + { + if (!string.IsNullOrEmpty(Heading)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"**{Heading}** (Chapter {ChapterNumber})"); + } + + sb.AppendLine(ChunkText); + sb.AppendLine(); + } +} + +public sealed record ListingWithContextTextResult( + ListingSourceCodeTextResult Listing, + IReadOnlyList RelatedBookExplanations) +{ + public string ToMcpString() + { + StringBuilder sb = new(); + Listing.AppendTo(sb, "##"); + sb.AppendLine(); + + if (RelatedBookExplanations.Count > 0) + { + sb.AppendLine("### Related Book Explanations"); + sb.AppendLine(); + + foreach (RelatedBookExplanationTextResult explanation in RelatedBookExplanations) + { + explanation.AppendTo(sb); + } + } + + return sb.ToString(); + } +} + +public sealed record ListingSearchTextResult( + string Pattern, + IReadOnlyList Matches) +{ + public string ToMcpString() + { + StringBuilder sb = new(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Listings Containing '{Pattern}' ({Matches.Count} result{(Matches.Count == 1 ? "" : "s")})"); + sb.AppendLine(); + + foreach (ListingSourceCodeTextResult match in Matches) + { + match.AppendTo(sb, "###"); + sb.AppendLine(); + } + + return sb.ToString(); + } +} diff --git a/EssentialCSharp.Web/Models/McpSearchTextResults.cs b/EssentialCSharp.Web/Models/McpSearchTextResults.cs new file mode 100644 index 00000000..5795a4b9 --- /dev/null +++ b/EssentialCSharp.Web/Models/McpSearchTextResults.cs @@ -0,0 +1,243 @@ +using System.Globalization; +using System.Text; + +namespace EssentialCSharp.Web.Models; + +public sealed record SearchBookContentMatchTextResult( + double Score, + int? ChapterNumber, + string? Heading, + string ChunkText) +{ + internal void AppendTo(StringBuilder sb, int index) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"--- Result {index} (Score: {Score:F4}) ---"); + + if (ChapterNumber.HasValue) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"Chapter: {ChapterNumber}"); + } + + if (!string.IsNullOrEmpty(Heading)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"Section: {Heading}"); + } + + sb.AppendLine(); + sb.AppendLine(ChunkText); + sb.AppendLine(); + } +} + +public sealed record SearchBookContentTextResult( + IReadOnlyList Matches) +{ + public string ToMcpString() + { + StringBuilder sb = new(); + + for (int i = 0; i < Matches.Count; i++) + { + Matches[i].AppendTo(sb, i + 1); + } + + return sb.ToString(); + } +} + +public sealed record BookSectionLinkTextResult( + string Heading, + int ChapterNumber, + string Link) +{ + internal void AppendBulletTo(StringBuilder sb) => + sb.AppendLine(CultureInfo.InvariantCulture, $"- **{Heading}** (Ch. {ChapterNumber}) — `{Link}`"); + + internal void AppendIndentedBulletTo(StringBuilder sb) => + sb.AppendLine(CultureInfo.InvariantCulture, $" - {Heading} (Ch. {ChapterNumber}) — `{Link}`"); +} + +public sealed record SemanticBookContentMatchTextResult( + int ChapterNumber, + string Heading, + string Excerpt) +{ + internal void AppendTo(StringBuilder sb) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"- **{Heading}** (Ch. {ChapterNumber})"); + sb.AppendLine(CultureInfo.InvariantCulture, $" > {Excerpt}..."); + } +} + +public sealed record LookupConceptTextResult( + string Concept, + IReadOnlyList HeadingMatches, + IReadOnlyList SemanticMatches) +{ + public string ToMcpString() + { + StringBuilder sb = new(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Book Coverage: '{Concept}'"); + sb.AppendLine(); + + if (HeadingMatches.Count > 0) + { + sb.AppendLine("## Sections with matching headings"); + foreach (BookSectionLinkTextResult headingMatch in HeadingMatches) + { + headingMatch.AppendBulletTo(sb); + } + + sb.AppendLine(); + } + + if (SemanticMatches.Count > 0) + { + sb.AppendLine("## Related content (semantic search)"); + foreach (SemanticBookContentMatchTextResult semanticMatch in SemanticMatches) + { + semanticMatch.AppendTo(sb); + } + + sb.AppendLine(); + } + + return sb.ToString(); + } +} + +public sealed record TopicCoverageTextResult( + string Topic, + string Assessment, + IReadOnlyList RelevantSections) +{ + public string ToMcpString() + { + StringBuilder sb = new(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Topic Coverage: '{Topic}'"); + sb.AppendLine(); + sb.AppendLine(CultureInfo.InvariantCulture, $"**Assessment:** {Assessment}"); + + if (RelevantSections.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("**Relevant sections:**"); + foreach (BookSectionLinkTextResult section in RelevantSections) + { + section.AppendIndentedBulletTo(sb); + } + } + + return sb.ToString(); + } +} + +public sealed record DiagnosticBookContentMatchTextResult( + int? ChapterNumber, + string? Heading, + string ChunkText) +{ + internal void AppendTo(StringBuilder sb) + { + if (!string.IsNullOrEmpty(Heading)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"**{Heading}** (Ch. {ChapterNumber})"); + } + + sb.AppendLine(ChunkText); + sb.AppendLine(); + } +} + +public sealed record DiagnosticHelpTextResult( + string Diagnostic, + string? SearchTerm, + IReadOnlyList RelevantSections, + IReadOnlyList RelevantBookContent, + IReadOnlyList RelatedGuidelines) +{ + public string ToMcpString() + { + StringBuilder sb = new(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Book Help for: {Diagnostic}"); + + if (!string.IsNullOrEmpty(SearchTerm)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"Searching for: '{SearchTerm}'"); + } + + sb.AppendLine(); + + if (RelevantSections.Count > 0) + { + sb.AppendLine("## Relevant Book Sections"); + foreach (BookSectionLinkTextResult section in RelevantSections) + { + section.AppendBulletTo(sb); + } + + sb.AppendLine(); + } + + if (RelevantBookContent.Count > 0) + { + sb.AppendLine("## Relevant Book Content"); + foreach (DiagnosticBookContentMatchTextResult match in RelevantBookContent) + { + match.AppendTo(sb); + } + } + + if (RelatedGuidelines.Count > 0) + { + sb.AppendLine("## Related Guidelines"); + foreach (TextGuidelineResult guideline in RelatedGuidelines) + { + guideline.AppendTo(sb); + } + } + + return sb.ToString(); + } +} + +public sealed record RelatedSectionMatchTextResult( + string Heading, + string Location, + string Excerpt) +{ + internal void AppendTo(StringBuilder sb) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"- **{Heading}** ({Location})"); + sb.AppendLine(CultureInfo.InvariantCulture, $" > {Excerpt}..."); + sb.AppendLine(); + } +} + +public sealed record RelatedSectionsTextResult( + string Heading, + int ChapterNumber, + string ChapterTitle, + IReadOnlyList RelatedSections) +{ + public string ToMcpString() + { + StringBuilder sb = new(); + sb.AppendLine(CultureInfo.InvariantCulture, $"# Sections Related to: {Heading}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"(Chapter {ChapterNumber}: {ChapterTitle})"); + sb.AppendLine(); + + if (RelatedSections.Count == 0) + { + sb.AppendLine("No related sections found."); + return sb.ToString(); + } + + foreach (RelatedSectionMatchTextResult relatedSection in RelatedSections) + { + relatedSection.AppendTo(sb); + } + + return sb.ToString(); + } +} diff --git a/EssentialCSharp.Web/Models/McpToolResultFactory.cs b/EssentialCSharp.Web/Models/McpToolResultFactory.cs new file mode 100644 index 00000000..b0cc4663 --- /dev/null +++ b/EssentialCSharp.Web/Models/McpToolResultFactory.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Text.Json; +using ModelContextProtocol.Protocol; + +namespace EssentialCSharp.Web.Models; + +public static class McpToolResultFactory +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public static CallToolResult CreateHybridResult(string readableText, T structuredContent) + { + string jsonText = JsonSerializer.Serialize(structuredContent, JsonOptions); + + return new CallToolResult + { + Content = new List + { + CreateTextContentBlock(readableText), + CreateTextContentBlock(jsonText) + }, + StructuredContent = JsonSerializer.SerializeToElement(structuredContent, JsonOptions) + }; + } + + public static CallToolResult CreateError(string message) => + new() + { + IsError = true, + Content = new List + { + CreateTextContentBlock(message) + } + }; + + private static TextContentBlock CreateTextContentBlock(string text) => new() { Text = text }; +} diff --git a/EssentialCSharp.Web/Models/McpToolResults.cs b/EssentialCSharp.Web/Models/McpToolResults.cs new file mode 100644 index 00000000..2cfda2ff --- /dev/null +++ b/EssentialCSharp.Web/Models/McpToolResults.cs @@ -0,0 +1,72 @@ +namespace EssentialCSharp.Web.Models; + +public sealed record BookTocItemResult( + string Key, + string Title, + string Href, + string Url, + int Level, + IReadOnlyList Items); + +public sealed record ChapterListToolResult( + string Title, + IReadOnlyList Chapters); + +public sealed record BookSectionReferenceResult( + string Key, + string Heading, + int ChapterNumber, + string ChapterTitle, + int IndentLevel, + string? AnchorId, + string Href, + string Url); + +public sealed record ChapterSectionsToolResult( + int ChapterNumber, + string ChapterTitle, + IReadOnlyList Sections); + +public sealed record NavigationContextToolResult( + BookSectionReferenceResult Section, + IReadOnlyList Breadcrumb, + BookSectionReferenceResult? Parent, + BookSectionReferenceResult? Previous, + BookSectionReferenceResult? Next, + IReadOnlyList Siblings); + +public sealed record BookGuidelineSummaryResult( + string Type, + string Guideline, + int ChapterNumber, + string ChapterTitle, + string Subsection); + +public sealed record BookContentExcerptResult( + int? ChapterNumber, + string? Heading, + string ChunkText); + +public sealed record ChapterSummaryToolResult( + int ChapterNumber, + string ChapterTitle, + IReadOnlyList Sections, + IReadOnlyList Guidelines); + +public sealed record DiagnosticHelpToolResult( + string Diagnostic, + string? SearchTerm, + IReadOnlyList RelevantSections, + IReadOnlyList RelevantBookContent, + IReadOnlyList RelatedGuidelines, + bool SemanticSearchAvailable); + +public sealed record ListingSourceCodeResult( + int ChapterNumber, + int ListingNumber, + string LanguageHint, + string Content); + +public sealed record ListingSearchToolResult( + string Pattern, + IReadOnlyList Matches); diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 899d5125..66633cdb 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -23,6 +23,7 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; using OpenTelemetry; using OpenTelemetry.Instrumentation.AspNetCore; using OpenTelemetry.Metrics; @@ -234,6 +235,7 @@ private static void Main(string[] args) builder.Services.AddTransient(); } builder.Services.Configure(builder.Configuration.GetSection(AuthMessageSenderOptions.AuthMessageSender)); + builder.Services.Configure(builder.Configuration.GetSection(SiteSettings.SectionName)); // Add services to the container. builder.Services.AddRazorPages(); @@ -241,6 +243,7 @@ private static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); // Add AI Chat services @@ -508,7 +511,7 @@ await context.HttpContext.Response.WriteAsync( var logger = app.Services.GetRequiredService>(); // Extract base URL from configuration - var baseUrl = configuration.GetSection("SiteSettings")["BaseUrl"] ?? "https://essentialcsharp.com"; + var baseUrl = app.Services.GetRequiredService>().Value.BaseUrl; try { diff --git a/EssentialCSharp.Web/Services/BookToolQueryService.cs b/EssentialCSharp.Web/Services/BookToolQueryService.cs new file mode 100644 index 00000000..b0c6677a --- /dev/null +++ b/EssentialCSharp.Web/Services/BookToolQueryService.cs @@ -0,0 +1,246 @@ +using EssentialCSharp.Web.Extensions; +using EssentialCSharp.Web.Models; +using ModelContextProtocol; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Services; + +public sealed class BookToolQueryService : IBookToolQueryService +{ + private readonly ISiteMappingService _siteMappingService; + private readonly IGuidelinesService _guidelinesService; + private readonly string _siteUrl; + + public BookToolQueryService( + ISiteMappingService siteMappingService, + IGuidelinesService guidelinesService, + IOptions siteSettings) + { + _siteMappingService = siteMappingService; + _guidelinesService = guidelinesService; + _siteUrl = string.IsNullOrWhiteSpace(siteSettings.Value.BaseUrl) + ? "https://essentialcsharp.com" + : siteSettings.Value.BaseUrl.TrimEnd('/'); + } + + public ChapterListToolResult GetChapterList() + { + List chapters = _siteMappingService.GetTocData() + .Select(MapTocItem) + .ToList(); + + return new ChapterListToolResult("Essential C# - Table of Contents", chapters); + } + + public ChapterSectionsToolResult GetChapterSections(int chapter) + { + List sections = GetChapterMappings(chapter); + if (sections.Count == 0) + { + throw new McpException($"Chapter {chapter} not found. Use GetChapterList to see all available chapters."); + } + + List sectionResults = sections + .Select(ToSectionReference) + .ToList(); + + return new ChapterSectionsToolResult(chapter, sections[0].ChapterTitle, sectionResults); + } + + public BookSectionReferenceResult GetDirectContentUrl(string sectionKey) + { + if (string.IsNullOrWhiteSpace(sectionKey)) + { + throw new McpException("Section key must not be empty. Use GetChapterSections or GetChapterList to discover valid section slugs."); + } + + SiteMapping mapping = ResolveSection(sectionKey); + return ToSectionReference(mapping); + } + + public NavigationContextToolResult GetNavigationContext(string sectionKey) + { + if (string.IsNullOrWhiteSpace(sectionKey)) + { + throw new McpException("Section key must not be empty. Use GetChapterSections to discover valid section slugs."); + } + + SiteMapping mapping = ResolveSection(sectionKey); + List ordered = GetOrderedMappings(); + + int index = ordered.FindIndex(candidate => ReferenceEquals(candidate, mapping)); + if (index < 0) + { + throw new McpException($"Section '{sectionKey}' could not be located in the ordered mapping list."); + } + + List breadcrumb = []; + int targetIndent = mapping.IndentLevel; + for (int i = index - 1; i >= 0 && targetIndent > 0; i--) + { + if (ordered[i].ChapterNumber != mapping.ChapterNumber) + { + break; + } + + if (ordered[i].IndentLevel < targetIndent) + { + breadcrumb.Insert(0, ordered[i]); + targetIndent = ordered[i].IndentLevel; + } + } + + SiteMapping? parent = null; + if (mapping.IndentLevel > 0) + { + for (int i = index - 1; i >= 0; i--) + { + if (ordered[i].ChapterNumber != mapping.ChapterNumber) + { + break; + } + + if (ordered[i].IndentLevel == mapping.IndentLevel - 1) + { + parent = ordered[i]; + break; + } + } + } + + SiteMapping? previous = null; + for (int i = index - 1; i >= 0; i--) + { + if (ordered[i].ChapterNumber != mapping.ChapterNumber) + { + break; + } + + if (ordered[i].IndentLevel == mapping.IndentLevel) + { + previous = ordered[i]; + break; + } + } + + SiteMapping? next = null; + for (int i = index + 1; i < ordered.Count; i++) + { + if (ordered[i].ChapterNumber != mapping.ChapterNumber || ordered[i].IndentLevel < mapping.IndentLevel) + { + break; + } + + if (ordered[i].IndentLevel == mapping.IndentLevel) + { + next = ordered[i]; + break; + } + } + + List siblings = []; + if (parent is not null) + { + int parentIndex = ordered.FindIndex(candidate => ReferenceEquals(candidate, parent)); + for (int i = parentIndex + 1; i < ordered.Count; i++) + { + if (ordered[i].ChapterNumber != mapping.ChapterNumber || ordered[i].IndentLevel < mapping.IndentLevel) + { + break; + } + + if (ordered[i].IndentLevel == mapping.IndentLevel && !ReferenceEquals(ordered[i], mapping)) + { + siblings.Add(ToSectionReference(ordered[i])); + } + } + } + + return new NavigationContextToolResult( + ToSectionReference(mapping), + breadcrumb.Select(ToSectionReference).ToList(), + parent is null ? null : ToSectionReference(parent), + previous is null ? null : ToSectionReference(previous), + next is null ? null : ToSectionReference(next), + siblings); + } + + public ChapterSummaryToolResult GetChapterSummary(int chapter) + { + List chapterMappings = GetChapterMappings(chapter); + if (chapterMappings.Count == 0) + { + throw new McpException($"Chapter {chapter} not found in the book's table of contents."); + } + + List sections = chapterMappings + .Where(mapping => mapping.IndentLevel <= 1) + .Select(ToSectionReference) + .ToList(); + + List guidelines = _guidelinesService.Guidelines + .Where(guideline => guideline.ChapterNumber == chapter) + .Select(guideline => new BookGuidelineSummaryResult( + guideline.Type.ToDisplayString(), + guideline.Guideline, + guideline.ChapterNumber, + guideline.ChapterTitle ?? string.Empty, + guideline.SanitizedSubsection)) + .ToList(); + + return new ChapterSummaryToolResult(chapter, chapterMappings[0].ChapterTitle, sections, guidelines); + } + + private SiteMapping ResolveSection(string sectionKey) => + _siteMappingService.SiteMappings.Find(sectionKey) + ?? throw new McpException($"Section '{sectionKey}' not found. Use GetChapterSections or GetChapterList to discover valid section slugs."); + + private List GetOrderedMappings() => + _siteMappingService.SiteMappings + .OrderBy(mapping => mapping.ChapterNumber) + .ThenBy(mapping => mapping.PageNumber) + .ThenBy(mapping => mapping.OrderOnPage) + .ToList(); + + private List GetChapterMappings(int chapter) => + _siteMappingService.SiteMappings + .Where(mapping => mapping.ChapterNumber == chapter) + .OrderBy(mapping => mapping.PageNumber) + .ThenBy(mapping => mapping.OrderOnPage) + .ToList(); + + private BookTocItemResult MapTocItem(SiteMappingDto item) + { + string href = NormalizeHref(item.Href); + return new BookTocItemResult( + item.Key, + item.Title, + href, + BuildUrl(href), + item.Level, + item.Items.Select(MapTocItem).ToList()); + } + + private BookSectionReferenceResult ToSectionReference(SiteMapping mapping) + { + string key = mapping.Keys.FirstOrDefault() ?? mapping.PrimaryKey; + string href = BuildHref(key, mapping.AnchorId); + return new BookSectionReferenceResult( + key, + mapping.RawHeading, + mapping.ChapterNumber, + mapping.ChapterTitle, + mapping.IndentLevel, + mapping.AnchorId, + href, + BuildUrl(href)); + } + + private static string BuildHref(string key, string? anchorId) => + anchorId is null ? $"/{key}" : $"/{key}#{anchorId}"; + + private static string NormalizeHref(string href) => + href.StartsWith('/') ? href : $"/{href}"; + + private string BuildUrl(string href) => $"{_siteUrl}{href}"; +} diff --git a/EssentialCSharp.Web/Services/IBookToolQueryService.cs b/EssentialCSharp.Web/Services/IBookToolQueryService.cs new file mode 100644 index 00000000..5502b34f --- /dev/null +++ b/EssentialCSharp.Web/Services/IBookToolQueryService.cs @@ -0,0 +1,12 @@ +using EssentialCSharp.Web.Models; + +namespace EssentialCSharp.Web.Services; + +public interface IBookToolQueryService +{ + ChapterListToolResult GetChapterList(); + ChapterSectionsToolResult GetChapterSections(int chapter); + BookSectionReferenceResult GetDirectContentUrl(string sectionKey); + NavigationContextToolResult GetNavigationContext(string sectionKey); + ChapterSummaryToolResult GetChapterSummary(int chapter); +} diff --git a/EssentialCSharp.Web/Services/SiteSettings.cs b/EssentialCSharp.Web/Services/SiteSettings.cs new file mode 100644 index 00000000..77495e93 --- /dev/null +++ b/EssentialCSharp.Web/Services/SiteSettings.cs @@ -0,0 +1,8 @@ +namespace EssentialCSharp.Web.Services; + +public sealed class SiteSettings +{ + public const string SectionName = "SiteSettings"; + + public string BaseUrl { get; set; } = "https://essentialcsharp.com"; +} diff --git a/EssentialCSharp.Web/Tools/BookContentTool.cs b/EssentialCSharp.Web/Tools/BookContentTool.cs index df2f6003..419cc51b 100644 --- a/EssentialCSharp.Web/Tools/BookContentTool.cs +++ b/EssentialCSharp.Web/Tools/BookContentTool.cs @@ -1,11 +1,9 @@ -using System.ComponentModel; -using System.Globalization; -using System.Text; +using System.ComponentModel; using System.Text.RegularExpressions; using EssentialCSharp.Chat.Common.Services; using EssentialCSharp.Web.Extensions; +using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; -using HtmlAgilityPack; using ModelContextProtocol.Server; namespace EssentialCSharp.Web.Tools; @@ -14,21 +12,21 @@ namespace EssentialCSharp.Web.Tools; public sealed partial class BookContentTool { private readonly ISiteMappingService _siteMappingService; + private readonly IBookToolQueryService _bookToolQueryService; private readonly IListingSourceCodeService _listingService; - private readonly IGuidelinesService _guidelinesService; private readonly IWebHostEnvironment _environment; private readonly AISearchService? _searchService; public BookContentTool( ISiteMappingService siteMappingService, + IBookToolQueryService bookToolQueryService, IListingSourceCodeService listingService, - IGuidelinesService guidelinesService, IWebHostEnvironment environment, IServiceProvider serviceProvider) { _siteMappingService = siteMappingService; + _bookToolQueryService = bookToolQueryService; _listingService = listingService; - _guidelinesService = guidelinesService; _environment = environment; _searchService = serviceProvider.GetService(); } @@ -97,52 +95,8 @@ public async Task GetSectionContent( return $"Failed to read chapter HTML for section '{sectionKey}'."; } - HtmlDocument doc = new(); - doc.LoadHtml(htmlContent); - - var sectionNode = doc.DocumentNode.SelectSingleNode( - $"//div[@id='{mapping.AnchorId}' and contains(@class,'section-heading')]"); - - if (sectionNode is null) - { - return $"Section heading element not found for anchor '{mapping.AnchorId}'."; - } - - var parent = sectionNode.ParentNode; - var header = new StringBuilder(); - header.AppendLine(CultureInfo.InvariantCulture, $"## {mapping.RawHeading}"); - header.AppendLine(CultureInfo.InvariantCulture, $"Chapter {mapping.ChapterNumber}: {mapping.ChapterTitle}"); - header.AppendLine(); - - var body = new StringBuilder(); - bool collecting = false; - foreach (HtmlNode child in parent.ChildNodes) - { - if (!collecting) - { - if (child == sectionNode) collecting = true; - continue; - } - - // Stop at the next section heading div with an id attribute - if (child.Name == "div" && - child.HasAttributes && - !string.IsNullOrEmpty(child.GetAttributeValue("id", "")) && - child.GetAttributeValue("class", "").Contains("section-heading")) - { - break; - } - - ExtractNodeContent(child, body); - - if (body.Length >= maxChars) - { - body.Append("\n\n[Content truncated — use a larger maxChars value to see more.]"); - break; - } - } - - return body.Length == 0 ? $"No content found after section heading '{mapping.RawHeading}'." : header.Append(body).ToString(); + SectionContentExtractionResult extraction = SectionContentExtractionResult.FromHtml(mapping, htmlContent, maxChars); + return extraction.ErrorMessage ?? extraction.Content!.ToMcpString(); } [McpServerTool(Title = "Get Listing With Context", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), @@ -159,12 +113,7 @@ public async Task GetListingWithContext( } string langHint = response.FileExtension == "cs" ? "csharp" : response.FileExtension; - var sb = new StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"## Listing {response.ChapterNumber}.{response.ListingNumber}"); - sb.AppendLine(CultureInfo.InvariantCulture, $"```{langHint}"); - sb.AppendLine(response.Content); - sb.AppendLine("```"); - sb.AppendLine(); + List explanations = []; if (_searchService is not null) { @@ -172,264 +121,32 @@ public async Task GetListingWithContext( var contextResults = await _searchService.ExecuteVectorSearch(query, cancellationToken: cancellationToken); if (contextResults.Count > 0) { - sb.AppendLine("### Related Book Explanations"); - sb.AppendLine(); - int count = 0; - foreach (var result in contextResults) + foreach (var result in contextResults.Take(3)) { - if (count++ >= 3) break; - if (!string.IsNullOrEmpty(result.Record.Heading)) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"**{result.Record.Heading}** (Chapter {result.Record.ChapterNumber})"); - } - sb.AppendLine(result.Record.ChunkText); - sb.AppendLine(); + explanations.Add(new RelatedBookExplanationTextResult( + result.Record.Heading, + result.Record.ChapterNumber, + result.Record.ChunkText)); } } } - return sb.ToString(); + return new ListingWithContextTextResult( + new ListingSourceCodeTextResult(response.ChapterNumber, response.ListingNumber, langHint, response.Content), + explanations).ToMcpString(); } - [McpServerTool(Title = "Get Navigation Context", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Navigation Context", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true), Description("Get the navigation context for a book section: its breadcrumb path, the previous and next sections, its parent section, and its sibling sections. Useful for understanding where a section sits in the book's structure.")] - public string GetNavigationContext( - [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections to get valid slugs.")] string sectionKey) - { - if (string.IsNullOrWhiteSpace(sectionKey)) - { - return "Section key must not be empty. Use GetChapterSections to discover valid section slugs."; - } + public NavigationContextToolResult GetNavigationContext( + [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections to get valid slugs.")] string sectionKey) => + _bookToolQueryService.GetNavigationContext(sectionKey); - SiteMapping? mapping = _siteMappingService.SiteMappings.Find(sectionKey); - if (mapping is null) - { - return $"Section '{sectionKey}' not found. Use GetChapterSections to discover valid section slugs."; - } - - var ordered = _siteMappingService.SiteMappings - .OrderBy(m => m.ChapterNumber) - .ThenBy(m => m.PageNumber) - .ThenBy(m => m.OrderOnPage) - .ToList(); - - int idx = ordered.FindIndex(m => ReferenceEquals(m, mapping)); - if (idx < 0) - { - return $"Section '{sectionKey}' could not be located in the ordered mapping list."; - } - - var sb = new StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"## Navigation Context: {mapping.RawHeading}"); - sb.AppendLine(CultureInfo.InvariantCulture, $"Chapter {mapping.ChapterNumber}: {mapping.ChapterTitle} | Indent level: {mapping.IndentLevel}"); - sb.AppendLine(); - - // Breadcrumb: ancestors (same chapter, descending indent levels) - var breadcrumb = new List(); - int targetIndent = mapping.IndentLevel; - for (int i = idx - 1; i >= 0 && targetIndent > 0; i--) - { - if (ordered[i].ChapterNumber != mapping.ChapterNumber) break; - if (ordered[i].IndentLevel < targetIndent) - { - breadcrumb.Insert(0, ordered[i]); - targetIndent = ordered[i].IndentLevel; - } - } - if (breadcrumb.Count > 0) - { - sb.Append("**Breadcrumb:** "); - sb.AppendJoin(" > ", breadcrumb.Select(m => m.RawHeading)); - sb.AppendLine(CultureInfo.InvariantCulture, $" > {mapping.RawHeading}"); - sb.AppendLine(); - } - - // Parent: nearest preceding mapping in same chapter with indent level - 1 - SiteMapping? parent = null; - if (mapping.IndentLevel > 0) - { - for (int i = idx - 1; i >= 0; i--) - { - if (ordered[i].ChapterNumber != mapping.ChapterNumber) break; - if (ordered[i].IndentLevel == mapping.IndentLevel - 1) - { - parent = ordered[i]; - break; - } - } - } - if (parent is not null) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"**Parent:** {parent.RawHeading} (`/{parent.Keys.FirstOrDefault() ?? parent.PrimaryKey}#{parent.AnchorId}`)"); - sb.AppendLine(); - } - - // Previous section at same indent level in the same chapter - SiteMapping? prev = null; - for (int i = idx - 1; i >= 0; i--) - { - if (ordered[i].ChapterNumber != mapping.ChapterNumber) break; - if (ordered[i].IndentLevel == mapping.IndentLevel) - { - prev = ordered[i]; - break; - } - } - if (prev is not null) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"**Previous:** {prev.RawHeading} (`/{prev.Keys.FirstOrDefault() ?? prev.PrimaryKey}#{prev.AnchorId}`)"); - } - - // Next section at same indent level in the same chapter - SiteMapping? next = null; - for (int i = idx + 1; i < ordered.Count; i++) - { - if (ordered[i].ChapterNumber != mapping.ChapterNumber) break; - if (ordered[i].IndentLevel < mapping.IndentLevel) break; - if (ordered[i].IndentLevel == mapping.IndentLevel) - { - next = ordered[i]; - break; - } - } - if (next is not null) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"**Next:** {next.RawHeading} (`/{next.Keys.FirstOrDefault() ?? next.PrimaryKey}#{next.AnchorId}`)"); - } - - // Siblings: all siblings sharing the same parent - if (parent is not null) - { - int parentIdx = ordered.FindIndex(m => ReferenceEquals(m, parent)); - var siblings = new List(); - for (int i = parentIdx + 1; i < ordered.Count; i++) - { - if (ordered[i].ChapterNumber != mapping.ChapterNumber) break; - if (ordered[i].IndentLevel < mapping.IndentLevel) break; - if (ordered[i].IndentLevel == mapping.IndentLevel && ordered[i] != mapping) - { - siblings.Add(ordered[i]); - } - } - if (siblings.Count > 0) - { - sb.AppendLine(); - sb.AppendLine("**Sibling sections:**"); - foreach (var s in siblings) - { - sb.AppendLine(CultureInfo.InvariantCulture, $" - {s.RawHeading} (`/{s.Keys.FirstOrDefault() ?? s.PrimaryKey}#{s.AnchorId}`)"); - } - } - } - - return sb.ToString(); - } - - [McpServerTool(Title = "Get Chapter Summary", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Chapter Summary", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true), Description("Get a structural overview of a book chapter: its top-level section headings in reading order, and the coding guidelines associated with that chapter. Useful for understanding what a chapter covers before diving in.")] - public string GetChapterSummary( - [Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) - { - var chapterMappings = _siteMappingService.SiteMappings - .Where(m => m.ChapterNumber == chapter) - .OrderBy(m => m.PageNumber) - .ThenBy(m => m.OrderOnPage) - .ToList(); - - if (chapterMappings.Count == 0) - { - return $"Chapter {chapter} not found in the book's table of contents."; - } - - string chapterTitle = chapterMappings.First().ChapterTitle; - - var sb = new StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"# Chapter {chapter}: {chapterTitle}"); - sb.AppendLine(); - sb.AppendLine("## Sections"); - - foreach (var m in chapterMappings.Where(m => m.IndentLevel <= 1)) - { - string indent = m.IndentLevel == 0 ? "" : " "; - string link = $"`/{m.Keys.FirstOrDefault() ?? m.PrimaryKey}#{m.AnchorId}`"; - sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}- {m.RawHeading} ({link})"); - } - - var guidelines = _guidelinesService.Guidelines - .Where(g => g.ChapterNumber == chapter) - .ToList(); - - if (guidelines.Count > 0) - { - sb.AppendLine(); - sb.AppendLine("## Guidelines in this Chapter"); - foreach (var g in guidelines) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"- **[{g.Type.ToDisplayString()}]** {g.Guideline}"); - } - } - - return sb.ToString(); - } - - private static void ExtractNodeContent(HtmlNode node, StringBuilder sb) - { - if (node.NodeType == HtmlNodeType.Text) - { - string text = HtmlEntity.DeEntitize(node.InnerText).Trim(); - if (!string.IsNullOrEmpty(text)) - { - sb.AppendLine(text); - } - return; - } - - if (node.Name is not ("div" or "p" or "ul" or "ol" or "li" or "span")) return; - - string nodeClass = node.GetAttributeValue("class", ""); - - // Code block: extract heading + code lines - if (nodeClass.Contains("code-block-section")) - { - var headingNode = node.SelectSingleNode(".//div[contains(@class,'code-block-heading')]"); - if (headingNode is not null) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"\n**{HtmlEntity.DeEntitize(headingNode.InnerText).Trim()}**"); - } - - sb.AppendLine("```csharp"); - var codeLines = node.SelectNodes(".//div[contains(@class,'code-line')]"); - if (codeLines is not null) - { - foreach (var lineClone in codeLines.Select(line => line.CloneNode(deep: true))) - { - var lineNumberSpan = lineClone.SelectSingleNode(".//span[contains(@class,'code-line-number')]"); - lineNumberSpan?.Remove(); - sb.AppendLine(HtmlEntity.DeEntitize(lineClone.InnerText)); - } - } - sb.AppendLine("```"); - return; - } - - // Paragraphs and other content - if (node.Name is "p" || nodeClass.Contains("paragraph")) - { - string text = HtmlEntity.DeEntitize(node.InnerText).Trim(); - if (!string.IsNullOrEmpty(text)) - { - sb.AppendLine(text); - sb.AppendLine(); - } - return; - } - - // Recurse into other divs (skill-topic-block, etc.) - foreach (HtmlNode child in node.ChildNodes) - { - ExtractNodeContent(child, sb); - } - } + public ChapterSummaryToolResult GetChapterSummary( + [Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) => + _bookToolQueryService.GetChapterSummary(chapter); [GeneratedRegex(@"^[A-Za-z0-9_-]{1,128}$")] private static partial Regex AnchorIdRegex(); diff --git a/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs index 4e4f7bf3..6784c79a 100644 --- a/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs +++ b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs @@ -1,7 +1,6 @@ using System.ComponentModel; -using System.Globalization; -using System.Text; using EssentialCSharp.Web.Extensions; +using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; using ModelContextProtocol.Server; @@ -68,18 +67,16 @@ public string GetCSharpGuidelines( return $"No guidelines found related to '{topic}'."; } - var topicSb = new StringBuilder(); - topicSb.AppendLine(CultureInfo.InvariantCulture, $"# Essential C# Guidelines — Topic: {topic} ({scored.Count} result{(scored.Count == 1 ? "" : "s")})"); - topicSb.AppendLine(); - - foreach (var (g, _) in scored) - { - topicSb.AppendLine(CultureInfo.InvariantCulture, $"**[{g.Type.ToDisplayString()}]** {g.Guideline}"); - topicSb.AppendLine(CultureInfo.InvariantCulture, $" — Chapter {g.ChapterNumber}: {g.ChapterTitle} / {g.SanitizedSubsection}"); - topicSb.AppendLine(); - } + List topicResults = scored + .Select(x => new TextGuidelineResult( + x.guideline.Type.ToDisplayString(), + x.guideline.Guideline, + x.guideline.ChapterNumber, + x.guideline.ChapterTitle ?? string.Empty, + x.guideline.SanitizedSubsection)) + .ToList(); - return topicSb.ToString(); + return new GuidelinesTextResult(topic, topicResults).ToMcpString(); } var results = filtered.Take(maxResults).ToList(); @@ -89,18 +86,16 @@ public string GetCSharpGuidelines( return "No guidelines found matching the specified filters."; } - var sb = new StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"# Essential C# Guidelines ({results.Count} result{(results.Count == 1 ? "" : "s")})"); - sb.AppendLine(); - - foreach (var g in results) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"**[{g.Type.ToDisplayString()}]** {g.Guideline}"); - sb.AppendLine(CultureInfo.InvariantCulture, $" — Chapter {g.ChapterNumber}: {g.ChapterTitle} / {g.SanitizedSubsection}"); - sb.AppendLine(); - } + List guidelineResults = results + .Select(g => new TextGuidelineResult( + g.Type.ToDisplayString(), + g.Guideline, + g.ChapterNumber, + g.ChapterTitle ?? string.Empty, + g.SanitizedSubsection)) + .ToList(); - return sb.ToString(); + return new GuidelinesTextResult(null, guidelineResults).ToMcpString(); } private static GuidelineType? ParseGuidelineType(string? input) diff --git a/EssentialCSharp.Web/Tools/BookListingTool.cs b/EssentialCSharp.Web/Tools/BookListingTool.cs index 23413313..60795207 100644 --- a/EssentialCSharp.Web/Tools/BookListingTool.cs +++ b/EssentialCSharp.Web/Tools/BookListingTool.cs @@ -1,7 +1,7 @@ -using System.ComponentModel; -using System.Globalization; -using System.Text; +using System.ComponentModel; +using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; namespace EssentialCSharp.Web.Tools; @@ -31,27 +31,38 @@ public async Task GetListingSourceCode( return $"Listing {chapter}.{listing} not found. Verify that both the chapter and listing numbers are correct."; } - string langHint = response.FileExtension == "cs" ? "csharp" : response.FileExtension; - return $"## Listing {response.ChapterNumber}.{response.ListingNumber}\n\n```{langHint}\n{response.Content}\n```"; + return ToTextResult(new ListingSourceCodeResult( + response.ChapterNumber, + response.ListingNumber, + ToLanguageHint(response.FileExtension), + response.Content)) + .ToMcpString(); } - [McpServerTool(Title = "Search Listings By Code", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool( + Title = "Search Listings By Code", + ReadOnly = true, + Destructive = false, + Idempotent = true, + OpenWorld = false, + UseStructuredContent = true, + OutputSchemaType = typeof(ListingSearchToolResult)), Description("Search all code listings in the Essential C# book for a specific code pattern, keyword, or identifier. Searches actual C# source code (not prose). Useful for finding examples of Task.WhenAll, yield return, IDisposable, pattern matching, and similar code constructs.")] - public async Task SearchListingsByCode( + public async Task SearchListingsByCode( [Description("The code pattern or keyword to search for in listing source code (case-insensitive substring match).")] string pattern, [Description("Maximum number of matching listings to return (1–20).")] int maxResults = 10, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(pattern)) { - return "Pattern must not be empty."; + return McpToolResultFactory.CreateError("Pattern must not be empty."); } string trimmedPattern = pattern.Trim(); bool isKnownOperator = trimmedPattern is "=>" or "??" or "?." or "::" or "??=" or "==" or "!=" or "<=" or ">=" or "&&" or "||"; if (!isKnownOperator && trimmedPattern.Count(char.IsLetterOrDigit) < 2) { - return "Pattern must contain at least two letters or digits, or be a recognized C# operator (=>, ??, ?., ::, ??=, ==, !=, <=, >=, &&, ||)."; + return McpToolResultFactory.CreateError("Pattern must contain at least two letters or digits, or be a recognized C# operator (=>, ??, ?., ::, ??=, ==, !=, <=, >=, &&, ||)."); } maxResults = Math.Clamp(maxResults, 1, 20); @@ -61,37 +72,43 @@ public async Task SearchListingsByCode( .Distinct() .OrderBy(n => n); - var sb = new StringBuilder(); - int found = 0; + List matches = []; foreach (int chapterNumber in distinctChapters) { - if (found >= maxResults) break; + if (matches.Count >= maxResults) break; cancellationToken.ThrowIfCancellationRequested(); var listings = await _listingService.GetListingsByChapterAsync(chapterNumber); foreach (var listing in listings) { - if (found >= maxResults) break; + if (matches.Count >= maxResults) break; if (listing.Content.Contains(trimmedPattern, StringComparison.OrdinalIgnoreCase)) { - string langHint = listing.FileExtension == "cs" ? "csharp" : listing.FileExtension; - sb.AppendLine(CultureInfo.InvariantCulture, $"### Listing {listing.ChapterNumber}.{listing.ListingNumber}"); - sb.AppendLine(CultureInfo.InvariantCulture, $"```{langHint}"); - sb.AppendLine(listing.Content); - sb.AppendLine("```"); - sb.AppendLine(); - found++; + matches.Add(new ListingSourceCodeResult( + listing.ChapterNumber, + listing.ListingNumber, + ToLanguageHint(listing.FileExtension), + listing.Content)); } } } - if (found == 0) + ListingSearchToolResult structuredResult = new(trimmedPattern, matches); + if (matches.Count == 0) { - return $"No listings found containing '{pattern}'."; + return McpToolResultFactory.CreateHybridResult( + $"No listings found containing '{trimmedPattern}'.", + structuredResult); } - sb.Insert(0, $"# Listings Containing '{pattern}' ({found} result{(found == 1 ? "" : "s")})\n\n"); - return sb.ToString(); + return McpToolResultFactory.CreateHybridResult( + new ListingSearchTextResult(trimmedPattern, matches.Select(ToTextResult).ToList()).ToMcpString(), + structuredResult); } + + private static string ToLanguageHint(string fileExtension) => fileExtension == "cs" ? "csharp" : fileExtension; + + private static ListingSourceCodeTextResult ToTextResult(ListingSourceCodeResult listing) => + new(listing.ChapterNumber, listing.ListingNumber, listing.LanguageHint, listing.Content); } diff --git a/EssentialCSharp.Web/Tools/BookSearchTool.cs b/EssentialCSharp.Web/Tools/BookSearchTool.cs index d53f6506..85a9fcab 100644 --- a/EssentialCSharp.Web/Tools/BookSearchTool.cs +++ b/EssentialCSharp.Web/Tools/BookSearchTool.cs @@ -1,31 +1,32 @@ -using System.ComponentModel; -using System.Globalization; -using System.Text; +using System.ComponentModel; using System.Text.RegularExpressions; using EssentialCSharp.Chat.Common.Services; using EssentialCSharp.Web.Extensions; +using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; namespace EssentialCSharp.Web.Tools; -file static class BookMetadata -{ - public const string SiteUrl = "https://essentialcsharp.com"; -} - [McpServerToolType] public sealed class BookSearchTool { private readonly AISearchService? _SearchService; private readonly ISiteMappingService _SiteMappingService; private readonly IGuidelinesService _guidelinesService; + private readonly IBookToolQueryService _bookToolQueryService; - public BookSearchTool(IServiceProvider serviceProvider, ISiteMappingService siteMappingService, IGuidelinesService guidelinesService) + public BookSearchTool( + IServiceProvider serviceProvider, + ISiteMappingService siteMappingService, + IGuidelinesService guidelinesService, + IBookToolQueryService bookToolQueryService) { _SearchService = serviceProvider.GetService(); _SiteMappingService = siteMappingService; _guidelinesService = guidelinesService; + _bookToolQueryService = bookToolQueryService; } [McpServerTool(Title = "Search Book Content", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), @@ -49,125 +50,40 @@ public async Task SearchBookContent( return "Book search is not available in this environment (AI services are not configured)."; } - var results = await _SearchService.ExecuteVectorSearch(query, top: maxResults, cancellationToken: cancellationToken); - - var sb = new StringBuilder(); - int resultCount = 0; - - foreach (var result in results) - { - 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(); - } + List matches = (await _SearchService.ExecuteVectorSearch( + query, + top: maxResults, + cancellationToken: cancellationToken)) + .Select(result => new SearchBookContentMatchTextResult( + result.Score ?? 0, + result.Record.ChapterNumber, + result.Record.Heading, + result.Record.ChunkText)) + .ToList(); - if (resultCount == 0) + if (matches.Count == 0) { return "No results found for the given query."; } - return sb.ToString(); + return new SearchBookContentTextResult(matches).ToMcpString(); } - [McpServerTool(Title = "Get Chapter List", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Chapter List", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true), 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(); - } + public ChapterListToolResult GetChapterList() => _bookToolQueryService.GetChapterList(); - [McpServerTool(Title = "Get Chapter Sections", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool(Title = "Get Chapter Sections", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true), Description("Get all sections and subsections in a specific chapter of the Essential C# book, in reading order. Returns each section's heading, slug, anchor link, and indent level. Use the returned slugs with other tools like GetSectionContent or GetNavigationContext.")] - public string GetChapterSections( - [Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) - { - var sections = _SiteMappingService.SiteMappings - .Where(m => m.ChapterNumber == chapter) - .OrderBy(m => m.PageNumber) - .ThenBy(m => m.OrderOnPage) - .ToList(); - - if (sections.Count == 0) - { - return $"Chapter {chapter} not found. Use GetChapterList to see all available chapters."; - } - - string chapterTitle = sections.First().ChapterTitle; - var sb = new StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"# Chapter {chapter}: {chapterTitle} — Sections"); - sb.AppendLine(); - - foreach (var m in sections) - { - string indent = new(' ', m.IndentLevel * 2); - string slug = m.Keys.FirstOrDefault() ?? m.PrimaryKey; - string anchor = m.AnchorId is not null ? $"#{m.AnchorId}" : ""; - sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}- {m.RawHeading} (slug: `{slug}`, link: `/{slug}{anchor}`)"); - } + public ChapterSectionsToolResult GetChapterSections( + [Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) => + _bookToolQueryService.GetChapterSections(chapter); - return sb.ToString(); - } - - [McpServerTool(Title = "Get Direct Content URL", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), - Description("Get the canonical deep-link URL for a specific book section or subsection. Returns a clickable URL that navigates directly to the section. Use this to include precise references in responses.")] - public string GetDirectContentUrl( - [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections or GetChapterList to find valid slugs.")] string sectionKey) - { - if (string.IsNullOrWhiteSpace(sectionKey)) - { - return "Section key must not be empty. Use GetChapterSections or GetChapterList to discover valid section slugs."; - } - - SiteMapping? mapping = _SiteMappingService.SiteMappings.Find(sectionKey); - if (mapping is null) - { - return $"Section '{sectionKey}' not found. Use GetChapterSections or GetChapterList to find valid section slugs."; - } - - string slug = mapping.Keys.FirstOrDefault() ?? mapping.PrimaryKey; - string anchor = mapping.AnchorId is not null ? $"#{mapping.AnchorId}" : ""; - string url = $"{BookMetadata.SiteUrl}/{slug}{anchor}"; - - return $"**{mapping.RawHeading}**\n" + - $"Chapter {mapping.ChapterNumber}: {mapping.ChapterTitle}\n" + - $"URL: {url}"; - } + [McpServerTool(Title = "Get Direct Content URL", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true), + Description("Get the canonical deep-link URL and section metadata for a specific book section or subsection. Use this to include precise references in responses.")] + public BookSectionReferenceResult GetDirectContentUrl( + [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections or GetChapterList to find valid slugs.")] string sectionKey) => + _bookToolQueryService.GetDirectContentUrl(sectionKey); [McpServerTool(Title = "Lookup Concept", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), Description("Find all sections in the Essential C# book that cover a specific C# concept. Combines section heading search with semantic vector search (when available) to give broad coverage. Returns section slugs, chapter numbers, and direct links.")] @@ -195,54 +111,35 @@ public async Task LookupConcept( .ThenBy(m => m.OrderOnPage) .ToList(); - // Vector search results - var vectorMatches = new List<(int chapter, string heading, string chunkText)>(); + List semanticMatches = []; if (_SearchService is not null) { var results = await _SearchService.ExecuteVectorSearch(concept, top: maxResults, cancellationToken: cancellationToken); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var r in results) { - vectorMatches.Add((r.Record.ChapterNumber ?? 0, r.Record.Heading ?? "", r.Record.ChunkText)); - } - } - - var sb = new StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"# Book Coverage: '{concept}'"); - sb.AppendLine(); - - if (headingMatches.Count > 0) - { - sb.AppendLine("## Sections with matching headings"); - foreach (var m in headingMatches) - { - string slug = m.Keys.FirstOrDefault() ?? m.PrimaryKey; - string anchor = m.AnchorId is not null ? $"#{m.AnchorId}" : ""; - sb.AppendLine(CultureInfo.InvariantCulture, - $"- **{m.RawHeading}** (Ch. {m.ChapterNumber}) — `/{slug}{anchor}`"); - } - sb.AppendLine(); - } + string heading = r.Record.Heading ?? ""; + if (!seen.Add(heading)) + { + continue; + } - if (vectorMatches.Count > 0) - { - sb.AppendLine("## Related content (semantic search)"); - // Deduplicate by heading - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var (ch, heading, text) in vectorMatches) - { - if (!seen.Add(heading)) continue; - sb.AppendLine(CultureInfo.InvariantCulture, $"- **{heading}** (Ch. {ch})"); - sb.AppendLine(CultureInfo.InvariantCulture, $" > {text[..Math.Min(200, text.Length)]}..."); + semanticMatches.Add(new SemanticBookContentMatchTextResult( + r.Record.ChapterNumber ?? 0, + heading, + r.Record.ChunkText[..Math.Min(200, r.Record.ChunkText.Length)])); } - sb.AppendLine(); } - if (headingMatches.Count == 0 && vectorMatches.Count == 0) + if (headingMatches.Count == 0 && semanticMatches.Count == 0) { return $"No book content found for '{concept}'. Try a different term or check the table of contents with GetChapterList."; } - return sb.ToString(); + return new LookupConceptTextResult( + concept, + headingMatches.Select(ToSectionLink).ToList(), + semanticMatches).ToMcpString(); } [McpServerTool(Title = "Check Topic Coverage", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), @@ -277,10 +174,6 @@ public async Task CheckTopicCoverage( hasSemanticCoverage = results.Count > 0; } - var sb = new StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"# Topic Coverage: '{topic}'"); - sb.AppendLine(); - string assessment; if (hasHeadingCoverage) { @@ -297,38 +190,37 @@ public async Task CheckTopicCoverage( : "**Not found in headings** — semantic search unavailable; topic may still be discussed in prose"; } - sb.AppendLine(CultureInfo.InvariantCulture, $"**Assessment:** {assessment}"); - - if (headingMatches.Count > 0) - { - sb.AppendLine(); - sb.AppendLine("**Relevant sections:**"); - foreach (var m in headingMatches.Take(5)) - { - string slug = m.Keys.FirstOrDefault() ?? m.PrimaryKey; - sb.AppendLine(CultureInfo.InvariantCulture, $" - {m.RawHeading} (Ch. {m.ChapterNumber}) — `/{slug}#{m.AnchorId}`"); - } - } - - return sb.ToString(); + return new TopicCoverageTextResult( + topic, + assessment, + headingMatches.Take(5).Select(ToSectionLink).ToList()).ToMcpString(); } - [McpServerTool(Title = "Find Book Help For Diagnostic", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), + [McpServerTool( + Title = "Find Book Help For Diagnostic", + ReadOnly = true, + Destructive = false, + Idempotent = true, + OpenWorld = false, + UseStructuredContent = true, + OutputSchemaType = typeof(DiagnosticHelpToolResult)), Description("Find Essential C# book sections, content, and coding guidelines that help explain a C# compiler error, warning, or runtime exception. Accepts a CS diagnostic code (e.g., 'CS8600') or a plain description (e.g., 'null reference exception', 'cannot implicitly convert'). Returns relevant sections, explanatory prose, and related guidelines.")] - public async Task FindBookHelpForDiagnostic( + public async Task FindBookHelpForDiagnostic( [Description("A C# compiler diagnostic code (e.g., 'CS8600', 'CS0029') or a plain error description (e.g., 'null reference exception', 'async method lacks await').")] string diagnostic, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(diagnostic)) { - return "Diagnostic must not be empty."; + return McpToolResultFactory.CreateError("Diagnostic must not be empty."); } - if (diagnostic.Length > 500) + + string trimmedDiagnostic = diagnostic.Trim(); + if (trimmedDiagnostic.Length > 500) { - return "Diagnostic is too long (maximum 500 characters)."; + return McpToolResultFactory.CreateError("Diagnostic is too long (maximum 500 characters)."); } - string searchTerm = MapDiagnosticToTopic(diagnostic); + string searchTerm = MapDiagnosticToTopic(trimmedDiagnostic); // Heading search var headingMatches = _SiteMappingService.SiteMappings @@ -336,27 +228,16 @@ public async Task FindBookHelpForDiagnostic( .Take(5) .ToList(); - // Vector search (buffered so we can check for any results before writing header) - bool hasVectorResults = false; - var vectorSb = new StringBuilder(); + List contentMatches = []; if (_SearchService is not null) { var vectorResults = await _SearchService.ExecuteVectorSearch(searchTerm, cancellationToken: cancellationToken); - if (vectorResults.Count > 0) + foreach (var r in vectorResults.Take(3)) { - hasVectorResults = true; - vectorSb.AppendLine("## Relevant Book Content"); - int count = 0; - foreach (var r in vectorResults) - { - if (count++ >= 3) break; - if (!string.IsNullOrEmpty(r.Record.Heading)) - { - vectorSb.AppendLine(CultureInfo.InvariantCulture, $"**{r.Record.Heading}** (Ch. {r.Record.ChapterNumber})"); - } - vectorSb.AppendLine(r.Record.ChunkText); - vectorSb.AppendLine(); - } + contentMatches.Add(new BookContentExcerptResult( + r.Record.ChapterNumber, + r.Record.Heading, + r.Record.ChunkText)); } } @@ -367,50 +248,35 @@ public async Task FindBookHelpForDiagnostic( .Take(3) .ToList(); - if (headingMatches.Count == 0 && !hasVectorResults && guidelineMatches.Count == 0) + List relevantSections = headingMatches.Select(ToSectionReference).ToList(); + List relatedGuidelines = guidelineMatches.Select(ToGuidelineSummary).ToList(); + DiagnosticHelpToolResult structuredResult = new( + trimmedDiagnostic, + string.Equals(searchTerm, trimmedDiagnostic, StringComparison.OrdinalIgnoreCase) ? null : searchTerm, + relevantSections, + contentMatches, + relatedGuidelines, + _SearchService is not null); + + if (headingMatches.Count == 0 && contentMatches.Count == 0 && guidelineMatches.Count == 0) { string semanticNote = _SearchService is null ? " Semantic search is also unavailable in this environment." : ""; - return $"No book content or guidelines found for '{diagnostic}'.{semanticNote} Try a broader description or use GetChapterList to explore the table of contents."; - } - - var sb = new StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"# Book Help for: {diagnostic}"); - if (!string.Equals(searchTerm, diagnostic, StringComparison.OrdinalIgnoreCase)) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"Searching for: '{searchTerm}'"); - } - sb.AppendLine(); - if (headingMatches.Count > 0) - { - sb.AppendLine("## Relevant Book Sections"); - foreach (var m in headingMatches) - { - string slug = m.Keys.FirstOrDefault() ?? m.PrimaryKey; - sb.AppendLine(CultureInfo.InvariantCulture, $"- **{m.RawHeading}** (Ch. {m.ChapterNumber}) — `/{slug}#{m.AnchorId}`"); - } - sb.AppendLine(); + return McpToolResultFactory.CreateHybridResult( + $"No book content or guidelines found for '{trimmedDiagnostic}'.{semanticNote} Try a broader description or use GetChapterList to explore the table of contents.", + structuredResult); } - if (vectorSb.Length > 0) - { - sb.Append(vectorSb); - } - - if (guidelineMatches.Count > 0) - { - sb.AppendLine("## Related Guidelines"); - foreach (var g in guidelineMatches) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"**[{g.Type.ToDisplayString()}]** {g.Guideline}"); - sb.AppendLine(CultureInfo.InvariantCulture, $" — Chapter {g.ChapterNumber}: {g.ChapterTitle} / {g.SanitizedSubsection}"); - sb.AppendLine(); - } - } - - return sb.ToString(); + return McpToolResultFactory.CreateHybridResult( + new DiagnosticHelpTextResult( + structuredResult.Diagnostic, + structuredResult.SearchTerm, + relevantSections.Select(ToTextResult).ToList(), + contentMatches.Select(ToTextResult).ToList(), + relatedGuidelines.Select(ToTextResult).ToList()).ToMcpString(), + structuredResult); } [McpServerTool(Title = "Find Related Sections", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false), @@ -439,18 +305,13 @@ public async Task FindRelatedSections( string query = $"{mapping.RawHeading} {mapping.ChapterTitle}"; var results = await _SearchService.ExecuteVectorSearch(query, top: maxResults, cancellationToken: cancellationToken); - var sb = new StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"# Sections Related to: {mapping.RawHeading}"); - sb.AppendLine(CultureInfo.InvariantCulture, $"(Chapter {mapping.ChapterNumber}: {mapping.ChapterTitle})"); - sb.AppendLine(); - + List relatedSections = []; var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { mapping.RawHeading }; - int count = 0; foreach (var r in results) { string heading = r.Record.Heading ?? ""; if (!seen.Add(heading)) continue; - if (count++ >= maxResults) break; + if (relatedSections.Count >= maxResults) break; // Find the SiteMapping for this heading to get the link SiteMapping? relatedMapping = _SiteMappingService.SiteMappings @@ -461,19 +322,38 @@ public async Task FindRelatedSections( ? $"`/{relatedMapping.Keys.FirstOrDefault() ?? relatedMapping.PrimaryKey}#{relatedMapping.AnchorId}`" : $"Ch. {r.Record.ChapterNumber}"; - sb.AppendLine(CultureInfo.InvariantCulture, $"- **{heading}** ({link})"); - sb.AppendLine(CultureInfo.InvariantCulture, $" > {r.Record.ChunkText[..Math.Min(200, r.Record.ChunkText.Length)]}..."); - sb.AppendLine(); - } - - if (count == 0) - { - sb.AppendLine("No related sections found."); + relatedSections.Add(new RelatedSectionMatchTextResult( + heading, + link, + r.Record.ChunkText[..Math.Min(200, r.Record.ChunkText.Length)])); } - return sb.ToString(); + return new RelatedSectionsTextResult(mapping.RawHeading, mapping.ChapterNumber, mapping.ChapterTitle, relatedSections) + .ToMcpString(); } + private BookSectionReferenceResult ToSectionReference(SiteMapping mapping) => + _bookToolQueryService.GetDirectContentUrl(mapping.Keys.FirstOrDefault() ?? mapping.PrimaryKey); + + private BookSectionLinkTextResult ToSectionLink(SiteMapping mapping) => ToTextResult(ToSectionReference(mapping)); + + private static BookSectionLinkTextResult ToTextResult(BookSectionReferenceResult section) => + new(section.Heading, section.ChapterNumber, section.Href); + + private static DiagnosticBookContentMatchTextResult ToTextResult(BookContentExcerptResult match) => + new(match.ChapterNumber, match.Heading, match.ChunkText); + + private static TextGuidelineResult ToTextResult(BookGuidelineSummaryResult guideline) => + new(guideline.Type, guideline.Guideline, guideline.ChapterNumber, guideline.ChapterTitle, guideline.Subsection); + + private static BookGuidelineSummaryResult ToGuidelineSummary(GuidelineListing guideline) => + new( + guideline.Type.ToDisplayString(), + guideline.Guideline, + guideline.ChapterNumber, + guideline.ChapterTitle ?? string.Empty, + guideline.SanitizedSubsection); + private static readonly Dictionary DiagnosticMap= new(StringComparer.OrdinalIgnoreCase) { // Nullable reference types From ca67d82d99a913e80df99b1adb8c2d1ae4bca4b3 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 28 Apr 2026 21:57:12 -0700 Subject: [PATCH 14/14] Fix rate limiting holes --- .../McpRateLimitingTests.cs | 214 ++++++++++++++++++ EssentialCSharp.Web.Tests/McpTestHelper.cs | 123 ++++++++++ EssentialCSharp.Web.Tests/McpTests.cs | 158 +++++-------- .../McpToolContractTests.cs | 73 ++---- .../Pages/Account/Manage/McpAccess.cshtml | 15 +- .../Pages/Account/Manage/McpAccess.cshtml.cs | 10 + .../Auth/McpApiKeyAuthenticationHandler.cs | 38 ++-- .../Auth/McpBearerAuthentication.cs | 55 +++++ .../Controllers/McpTokenController.cs | 3 +- EssentialCSharp.Web/Program.cs | 133 +++++++---- .../Services/McpApiTokenService.cs | 36 +-- .../Services/McpJsonRpcResponseWriter.cs | 34 +++ .../Services/McpRateLimiterPolicy.cs | 70 ++++++ .../Services/RateLimitingResponseHelpers.cs | 19 ++ 14 files changed, 747 insertions(+), 234 deletions(-) create mode 100644 EssentialCSharp.Web.Tests/McpRateLimitingTests.cs create mode 100644 EssentialCSharp.Web.Tests/McpTestHelper.cs create mode 100644 EssentialCSharp.Web/Auth/McpBearerAuthentication.cs create mode 100644 EssentialCSharp.Web/Services/McpJsonRpcResponseWriter.cs create mode 100644 EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs create mode 100644 EssentialCSharp.Web/Services/RateLimitingResponseHelpers.cs diff --git a/EssentialCSharp.Web.Tests/McpRateLimitingTests.cs b/EssentialCSharp.Web.Tests/McpRateLimitingTests.cs new file mode 100644 index 00000000..bab4518d --- /dev/null +++ b/EssentialCSharp.Web.Tests/McpRateLimitingTests.cs @@ -0,0 +1,214 @@ +using System.Net; +using System.Text.Json; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace EssentialCSharp.Web.Tests; + +/// +/// Each class gets its own factory so the global limiter starts from a fresh state. +/// +[NotInParallel("McpTests")] +[ClassDataSource(Shared = SharedType.PerClass)] +public class McpDistinctUserRateLimitingTests(WebApplicationFactory factory) +{ + [Test] + public async Task DistinctValidMcpUsers_DoNotShareRateLimitBucket() + { + HttpClient client = McpTestHelper.CreateClient(factory); + + for (int i = 0; i < 31; i++) + { + (_, string rawToken) = await McpTestHelper.CreateUserAndTokenAsync( + factory, + $"mcp-rate-limit-isolation-{i}", + userPrefix: $"mcp-isolation-{i}"); + + using var request = McpTestHelper.CreateInitializeRequest("/mcp"); + McpTestHelper.AddBearerToken(request, rawToken); + + using HttpResponseMessage response = await client.SendAsync(request); + await Assert.That(response.StatusCode) + .IsEqualTo(HttpStatusCode.OK) + .Because($"distinct MCP user request {i + 1} should use its own rate-limit bucket"); + } + } +} + +[NotInParallel("McpTests")] +[ClassDataSource(Shared = SharedType.PerClass)] +public class McpPerUserRateLimitingTests(WebApplicationFactory factory) +{ + [Test] + public async Task SingleValidMcpUser_ExceedingTokenBucket_Returns429AndDoesNotCountRejectedRequests() + { + (_, string rawToken) = await McpTestHelper.CreateUserAndTokenAsync( + factory, + "mcp-rate-limit-single-user", + userPrefix: "mcp-single-user"); + HttpClient client = McpTestHelper.CreateClient(factory); + List statuses = []; + string? rateLimitedPayload = null; + string? rateLimitedContentType = null; + TimeSpan? retryAfter = null; + int totalRequests = McpRateLimiterPolicy.AuthenticatedTokenLimit + 15; + + for (int i = 0; i < totalRequests; i++) + { + using var request = McpTestHelper.CreateInitializeRequest("/mcp"); + McpTestHelper.AddBearerToken(request, rawToken); + + using HttpResponseMessage response = await client.SendAsync(request); + statuses.Add(response.StatusCode); + if (response.StatusCode == HttpStatusCode.TooManyRequests && rateLimitedPayload is null) + { + rateLimitedPayload = await response.Content.ReadAsStringAsync(); + rateLimitedContentType = response.Content.Headers.ContentType?.MediaType; + retryAfter = response.Headers.RetryAfter?.Delta; + } + } + + (long UsageCount, bool HasLastUsedAt) tokenUsage = factory.InServiceScope(services => + { + var db = services.GetRequiredService(); + byte[] tokenHash = McpApiTokenService.HashToken(rawToken); + var token = db.McpApiTokens.Single(t => t.TokenHash == tokenHash); + return (token.UsageCount, token.LastUsedAt.HasValue); + }); + + await Assert.That(statuses.Take(McpRateLimiterPolicy.AuthenticatedTokenLimit) + .All(status => status == HttpStatusCode.OK)).IsTrue(); + await Assert.That(statuses.Skip(McpRateLimiterPolicy.AuthenticatedTokenLimit) + .Any(status => status == HttpStatusCode.TooManyRequests)).IsTrue(); + + int successCount = statuses.Count(status => status == HttpStatusCode.OK); + await Assert.That(successCount).IsLessThan(totalRequests); + await Assert.That(tokenUsage.UsageCount).IsEqualTo((long)successCount); + await Assert.That(tokenUsage.HasLastUsedAt).IsTrue(); + + string payload = rateLimitedPayload + ?? throw new InvalidOperationException("Expected at least one MCP token-bucket rejection."); + await Assert.That(rateLimitedContentType).IsEqualTo("application/json"); + + TimeSpan retryAfterDelta = retryAfter + ?? throw new InvalidOperationException("Expected Retry-After on the MCP token-bucket rejection."); + await Assert.That(retryAfterDelta.TotalSeconds).IsGreaterThan(0d); + + using JsonDocument document = JsonDocument.Parse(payload); + JsonElement root = document.RootElement; + await Assert.That(root.GetProperty("jsonrpc").GetString()).IsEqualTo("2.0"); + await Assert.That(root.GetProperty("id").ValueKind).IsEqualTo(JsonValueKind.Null); + JsonElement error = root.GetProperty("error"); + await Assert.That(error.GetProperty("code").GetInt32()).IsEqualTo(-32000); + await Assert.That(error.GetProperty("message").GetString()).Contains("Rate limit exceeded"); + } +} + +[NotInParallel("McpTests")] +[ClassDataSource(Shared = SharedType.PerClass)] +public class McpAnonymousRateLimitingTests(WebApplicationFactory factory) +{ + [Test] + public async Task InvalidMcpBearerRequests_FallBackToAnonymousIpBucket() + { + HttpClient client = McpTestHelper.CreateClient(factory); + + for (int i = 0; i < McpRateLimiterPolicy.AnonymousPermitLimit; i++) + { + using var request = McpTestHelper.CreateInitializeRequest("/mcp"); + McpTestHelper.AddBearerToken(request, "mcp_invalid_token_that_does_not_exist"); + + using HttpResponseMessage response = await client.SendAsync(request); + await Assert.That(response.StatusCode) + .IsEqualTo(HttpStatusCode.Unauthorized) + .Because($"invalid MCP bearer request {i + 1} should still challenge before the anonymous bucket is exhausted"); + } + + using var rateLimitedRequest = McpTestHelper.CreateInitializeRequest("/mcp"); + McpTestHelper.AddBearerToken(rateLimitedRequest, "mcp_invalid_token_that_does_not_exist"); + + using HttpResponseMessage rateLimitedResponse = await client.SendAsync(rateLimitedRequest); + await Assert.That(rateLimitedResponse.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests); + } +} + +[NotInParallel("McpTests")] +[ClassDataSource(Shared = SharedType.PerClass)] +public class McpCookieIsolationRateLimitingTests(WebApplicationFactory factory) +{ + [Test] + public async Task InvalidMcpBearerRequests_WithDifferentSiteCookies_StillShareAnonymousIpBucket() + { + HttpClient client = McpTestHelper.CreateClient(factory); + + for (int i = 0; i < McpRateLimiterPolicy.AnonymousPermitLimit; i++) + { + string cookieUserId = await McpTestHelper.CreateUserAsync(factory, $"mcp-cookie-user-{i}"); + (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(factory, cookieUserId); + + using var request = McpTestHelper.CreateInitializeRequest("/mcp"); + McpTestHelper.AddBearerToken(request, "mcp_invalid_token_that_does_not_exist"); + McpTestHelper.AddCookie(request, cookieName, cookieValue); + + using HttpResponseMessage response = await client.SendAsync(request); + await Assert.That(response.StatusCode) + .IsEqualTo(HttpStatusCode.Unauthorized) + .Because($"invalid MCP bearer request {i + 1} should ignore the site cookie principal and stay in the anonymous/IP bucket"); + } + + string finalCookieUserId = await McpTestHelper.CreateUserAsync(factory, "mcp-cookie-user-final"); + (string finalCookieName, string finalCookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(factory, finalCookieUserId); + + using var rateLimitedRequest = McpTestHelper.CreateInitializeRequest("/mcp"); + McpTestHelper.AddBearerToken(rateLimitedRequest, "mcp_invalid_token_that_does_not_exist"); + McpTestHelper.AddCookie(rateLimitedRequest, finalCookieName, finalCookieValue); + + using HttpResponseMessage rateLimitedResponse = await client.SendAsync(rateLimitedRequest); + await Assert.That(rateLimitedResponse.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests); + } +} + +[NotInParallel("McpTests")] +[ClassDataSource(Shared = SharedType.PerClass)] +public class McpGlobalBypassRateLimitingTests(WebApplicationFactory factory) +{ + [Test] + public async Task ValidMcpPostRequests_DoNotConsumeGlobalLimiterBudgetForGetShim() + { + (_, string rawToken) = await McpTestHelper.CreateUserAndTokenAsync( + factory, + "mcp-global-bypass", + userPrefix: "mcp-bypass"); + HttpClient client = McpTestHelper.CreateClient(factory); + + for (int i = 0; i < 10; i++) + { + using var postRequest = McpTestHelper.CreateInitializeRequest("/mcp"); + McpTestHelper.AddBearerToken(postRequest, rawToken); + + using HttpResponseMessage postResponse = await client.SendAsync(postRequest); + await Assert.That(postResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + for (int i = 0; i < 30; i++) + { + using var getRequest = new HttpRequestMessage(HttpMethod.Get, "/mcp"); + McpTestHelper.AddBearerToken(getRequest, rawToken); + getRequest.Headers.Accept.ParseAdd("text/event-stream"); + + using HttpResponseMessage getResponse = await client.SendAsync(getRequest); + await Assert.That(getResponse.StatusCode) + .IsEqualTo(HttpStatusCode.MethodNotAllowed) + .Because($"global request {i + 1} should still be within the non-MCP GET shim limit"); + } + + using var rateLimitedGetRequest = new HttpRequestMessage(HttpMethod.Get, "/mcp"); + McpTestHelper.AddBearerToken(rateLimitedGetRequest, rawToken); + rateLimitedGetRequest.Headers.Accept.ParseAdd("text/event-stream"); + + using HttpResponseMessage rateLimitedGetResponse = await client.SendAsync(rateLimitedGetRequest); + await Assert.That(rateLimitedGetResponse.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests); + } +} diff --git a/EssentialCSharp.Web.Tests/McpTestHelper.cs b/EssentialCSharp.Web.Tests/McpTestHelper.cs new file mode 100644 index 00000000..12ed4255 --- /dev/null +++ b/EssentialCSharp.Web.Tests/McpTestHelper.cs @@ -0,0 +1,123 @@ +using System.Net.Http.Headers; +using System.Text; +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Tests; + +internal static class McpTestHelper +{ + public static HttpClient CreateClient(WebApplicationFactory factory) => factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + public static HttpRequestMessage CreateInitializeRequest(string path = "/mcp") + { + 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") + }; + + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.Accept.ParseAdd("text/event-stream"); + return request; + } + + public static void AddBearerToken(HttpRequestMessage request, string rawToken) => + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); + + public static void AddCookie(HttpRequestMessage request, string cookieName, string cookieValue) => + request.Headers.Add("Cookie", $"{cookieName}={cookieValue}"); + + public static async Task CreateUserAsync(WebApplicationFactory factory, string userPrefix) + { + string userId = Guid.NewGuid().ToString(); + string suffix = Guid.NewGuid().ToString("N")[..8]; + string userName = $"{userPrefix.ToLowerInvariant()}-{suffix}"; + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Users.Add(new EssentialCSharpWebUser + { + Id = userId, + UserName = userName, + NormalizedUserName = userName.ToUpperInvariant(), + Email = $"{userName}@example.com", + NormalizedEmail = $"{userName.ToUpperInvariant()}@EXAMPLE.COM", + SecurityStamp = Guid.NewGuid().ToString(), + }); + await db.SaveChangesAsync(); + + return userId; + } + + public static async Task<(string UserId, string RawToken)> CreateUserAndTokenAsync( + WebApplicationFactory factory, + string tokenName, + string userPrefix = "mcp-test", + DateTime? expiresAt = null) + { + string userId = await CreateUserAsync(factory, userPrefix); + + using var scope = factory.Services.CreateScope(); + var tokenService = scope.ServiceProvider.GetRequiredService(); + (string rawToken, _) = expiresAt is { } expiry + ? await tokenService.CreateTokenAsync(userId, tokenName, expiry) + : await tokenService.CreateTokenAsync(userId, tokenName); + + return (userId, rawToken); + } + + public static async Task<(string CookieName, string CookieValue)> CreateIdentityApplicationCookieAsync( + WebApplicationFactory factory, + string userId) + { + using var scope = factory.Services.CreateScope(); + var signInManager = scope.ServiceProvider.GetRequiredService>(); + EssentialCSharpWebUser user = await signInManager.UserManager.FindByIdAsync(userId) + ?? throw new InvalidOperationException($"Could not find test user '{userId}' to create an identity cookie."); + var principal = await signInManager.CreateUserPrincipalAsync(user); + + CookieAuthenticationOptions cookieOptions = scope.ServiceProvider + .GetRequiredService>() + .Get(IdentityConstants.ApplicationScheme); + + string cookieName = cookieOptions.Cookie.Name + ?? throw new InvalidOperationException("Identity application cookie name is not configured."); + + var ticket = new AuthenticationTicket( + principal, + new AuthenticationProperties + { + IssuedUtc = DateTimeOffset.UtcNow, + ExpiresUtc = DateTimeOffset.UtcNow.Add(cookieOptions.ExpireTimeSpan), + }, + IdentityConstants.ApplicationScheme); + + string cookieValue = cookieOptions.TicketDataFormat.Protect(ticket) + ?? throw new InvalidOperationException("Failed to protect the identity application ticket."); + + return (cookieName, cookieValue); + } +} diff --git a/EssentialCSharp.Web.Tests/McpTests.cs b/EssentialCSharp.Web.Tests/McpTests.cs index d42f3f4c..ef506402 100644 --- a/EssentialCSharp.Web.Tests/McpTests.cs +++ b/EssentialCSharp.Web.Tests/McpTests.cs @@ -1,8 +1,5 @@ using System.Net; -using System.Net.Http.Headers; using System.Text; -using EssentialCSharp.Web.Areas.Identity.Data; -using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; @@ -16,10 +13,7 @@ public class McpTests(WebApplicationFactory factory) [Test] public async Task McpTokenEndpoint_WithoutAuth_Returns401() { - HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false - }); + HttpClient client = McpTestHelper.CreateClient(factory); using HttpResponseMessage response = await client.PostAsync("/api/McpToken", null); @@ -29,9 +23,9 @@ public async Task McpTokenEndpoint_WithoutAuth_Returns401() [Test] public async Task McpEndpoint_WithoutToken_Returns401() { - HttpClient client = factory.CreateClient(); + HttpClient client = McpTestHelper.CreateClient(factory); - using var request = CreateMcpInitializeRequest("/mcp"); + using var request = McpTestHelper.CreateInitializeRequest("/mcp"); using HttpResponseMessage response = await client.SendAsync(request); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); @@ -40,33 +34,16 @@ public async Task McpEndpoint_WithoutToken_Returns401() [Test] public async Task McpEndpoint_WithValidToken_Returns200AndListsTools() { - // Seed a minimal user row to satisfy the FK on McpApiToken.UserId, then - // create an opaque token via McpApiTokenService (replaces old JWT path). - string testUserId = Guid.NewGuid().ToString(); - string rawToken; - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.Users.Add(new EssentialCSharpWebUser - { - Id = testUserId, - UserName = "mcp-testuser", - NormalizedUserName = "MCP-TESTUSER", - Email = "mcp-test@example.com", - NormalizedEmail = "MCP-TEST@EXAMPLE.COM", - SecurityStamp = Guid.NewGuid().ToString(), - }); - await db.SaveChangesAsync(); - - var tokenService = scope.ServiceProvider.GetRequiredService(); - (rawToken, _) = await tokenService.CreateTokenAsync(testUserId, "integration-test"); - } + (_, string rawToken) = await McpTestHelper.CreateUserAndTokenAsync( + factory, + "integration-test", + userPrefix: "mcp-testuser"); - HttpClient client = factory.CreateClient(); + HttpClient client = McpTestHelper.CreateClient(factory); // Step 1: Initialize the MCP session - using var initRequest = CreateMcpInitializeRequest("/mcp"); - initRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); + using var initRequest = McpTestHelper.CreateInitializeRequest("/mcp"); + McpTestHelper.AddBearerToken(initRequest, rawToken); using HttpResponseMessage initResponse = await client.SendAsync(initRequest); await Assert.That(initResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); @@ -83,7 +60,7 @@ public async Task McpEndpoint_WithValidToken_Returns200AndListsTools() """{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""", Encoding.UTF8, "application/json") }; - listToolsRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); + McpTestHelper.AddBearerToken(listToolsRequest, rawToken); listToolsRequest.Headers.Accept.ParseAdd("application/json"); listToolsRequest.Headers.Accept.ParseAdd("text/event-stream"); if (sessionId is not null) @@ -115,9 +92,9 @@ public async Task McpEndpoint_WithValidToken_Returns200AndListsTools() [Test] public async Task McpEndpoint_WithInvalidToken_Returns401() { - HttpClient client = factory.CreateClient(); - using var request = CreateMcpInitializeRequest("/mcp"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "mcp_invalid_token_that_does_not_exist"); + HttpClient client = McpTestHelper.CreateClient(factory); + using var request = McpTestHelper.CreateInitializeRequest("/mcp"); + McpTestHelper.AddBearerToken(request, "mcp_invalid_token_that_does_not_exist"); using HttpResponseMessage response = await client.SendAsync(request); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } @@ -125,30 +102,18 @@ public async Task McpEndpoint_WithInvalidToken_Returns401() [Test] public async Task McpEndpoint_WithRevokedToken_Returns401() { - string testUserId = Guid.NewGuid().ToString(); + string testUserId = await McpTestHelper.CreateUserAsync(factory, "revoked-user"); string rawToken; using (var scope = factory.Services.CreateScope()) { - var db = scope.ServiceProvider.GetRequiredService(); - db.Users.Add(new EssentialCSharpWebUser - { - Id = testUserId, - UserName = $"revoked-user-{testUserId[..8]}", - NormalizedUserName = $"REVOKED-USER-{testUserId[..8].ToUpperInvariant()}", - Email = $"revoked-{testUserId[..8]}@example.com", - NormalizedEmail = $"REVOKED-{testUserId[..8].ToUpperInvariant()}@EXAMPLE.COM", - SecurityStamp = Guid.NewGuid().ToString(), - }); - await db.SaveChangesAsync(); - var tokenService = scope.ServiceProvider.GetRequiredService(); (rawToken, var entity) = await tokenService.CreateTokenAsync(testUserId, "revoke-test"); await tokenService.RevokeTokenAsync(entity.Id, testUserId); } - HttpClient client = factory.CreateClient(); - using var request = CreateMcpInitializeRequest("/mcp"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); + HttpClient client = McpTestHelper.CreateClient(factory); + using var request = McpTestHelper.CreateInitializeRequest("/mcp"); + McpTestHelper.AddBearerToken(request, rawToken); using HttpResponseMessage response = await client.SendAsync(request); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } @@ -156,57 +121,52 @@ public async Task McpEndpoint_WithRevokedToken_Returns401() [Test] public async Task McpEndpoint_WithExpiredToken_Returns401() { - string testUserId = Guid.NewGuid().ToString(); - string rawToken; - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.Users.Add(new EssentialCSharpWebUser - { - Id = testUserId, - UserName = $"expired-user-{testUserId[..8]}", - NormalizedUserName = $"EXPIRED-USER-{testUserId[..8].ToUpperInvariant()}", - Email = $"expired-{testUserId[..8]}@example.com", - NormalizedEmail = $"EXPIRED-{testUserId[..8].ToUpperInvariant()}@EXAMPLE.COM", - SecurityStamp = Guid.NewGuid().ToString(), - }); - await db.SaveChangesAsync(); + (_, string rawToken) = await McpTestHelper.CreateUserAndTokenAsync( + factory, + "expired-test", + userPrefix: "expired-user", + expiresAt: DateTime.UtcNow.AddSeconds(-1)); + + HttpClient client = McpTestHelper.CreateClient(factory); + using var request = McpTestHelper.CreateInitializeRequest("/mcp"); + McpTestHelper.AddBearerToken(request, rawToken); + using HttpResponseMessage response = await client.SendAsync(request); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } - var tokenService = scope.ServiceProvider.GetRequiredService(); - // Create with an expiry in the past (1 second ago) - DateTime pastExpiry = DateTime.UtcNow.AddSeconds(-1); - (rawToken, _) = await tokenService.CreateTokenAsync(testUserId, "expired-test", pastExpiry); - } + [Test] + public async Task McpEndpoint_PreflightFromLoopbackOrigin_ReturnsCorsHeaders() + { + HttpClient client = McpTestHelper.CreateClient(factory); + using var request = new HttpRequestMessage(HttpMethod.Options, "/mcp"); + request.Headers.Add("Origin", "http://localhost:6274"); + request.Headers.Add("Access-Control-Request-Method", "POST"); + request.Headers.Add("Access-Control-Request-Headers", "authorization,content-type"); - HttpClient client = factory.CreateClient(); - using var request = CreateMcpInitializeRequest("/mcp"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); using HttpResponseMessage response = await client.SendAsync(request); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + await Assert.That(response.Headers.TryGetValues("Access-Control-Allow-Origin", out IEnumerable? origins)).IsTrue(); + await Assert.That(origins?.SingleOrDefault()).IsEqualTo("http://localhost:6274"); + await Assert.That(response.Headers.TryGetValues("Access-Control-Allow-Methods", out IEnumerable? methods)).IsTrue(); + await Assert.That(methods?.SingleOrDefault()).Contains("POST"); + await Assert.That(response.Headers.TryGetValues("Access-Control-Allow-Headers", out IEnumerable? headers)).IsTrue(); + await Assert.That(headers?.SingleOrDefault()).Contains("authorization"); } - private static HttpRequestMessage CreateMcpInitializeRequest(string path) + [Test] + public async Task McpEndpoint_GetFromLoopbackOrigin_Returns405WithoutRedirect() { - 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"); + HttpClient client = McpTestHelper.CreateClient(factory); + using var request = new HttpRequestMessage(HttpMethod.Get, "/mcp"); + request.Headers.Add("Origin", "http://localhost:6274"); request.Headers.Accept.ParseAdd("text/event-stream"); - return request; + + using HttpResponseMessage response = await client.SendAsync(request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.MethodNotAllowed); + await Assert.That(response.Headers.Location).IsNull(); + await Assert.That(response.Headers.TryGetValues("Access-Control-Allow-Origin", out IEnumerable? origins)).IsTrue(); + await Assert.That(origins?.SingleOrDefault()).IsEqualTo("http://localhost:6274"); } -} \ No newline at end of file +} diff --git a/EssentialCSharp.Web.Tests/McpToolContractTests.cs b/EssentialCSharp.Web.Tests/McpToolContractTests.cs index 463cdf95..ec806220 100644 --- a/EssentialCSharp.Web.Tests/McpToolContractTests.cs +++ b/EssentialCSharp.Web.Tests/McpToolContractTests.cs @@ -1,9 +1,6 @@ using System.Net; -using System.Net.Http.Headers; using System.Text; using System.Text.Json; -using EssentialCSharp.Web.Areas.Identity.Data; -using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; @@ -203,11 +200,14 @@ public async Task McpCall_GetChapterSections_WithInvalidChapter_ReturnsMcpError( private async Task<(HttpClient Client, string RawToken, string? SessionId)> CreateAuthenticatedSessionAsync() { - string rawToken = await CreateTokenAsync(); - HttpClient client = factory.CreateClient(); + (_, string rawToken) = await McpTestHelper.CreateUserAndTokenAsync( + factory, + "mcp-contract-test", + userPrefix: "mcp-contract"); + HttpClient client = McpTestHelper.CreateClient(factory); - using var initRequest = CreateMcpInitializeRequest("/mcp"); - initRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); + using var initRequest = McpTestHelper.CreateInitializeRequest("/mcp"); + McpTestHelper.AddBearerToken(initRequest, rawToken); using HttpResponseMessage initResponse = await client.SendAsync(initRequest); await Assert.That(initResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); @@ -221,27 +221,6 @@ public async Task McpCall_GetChapterSections_WithInvalidChapter_ReturnsMcpError( return (client, rawToken, sessionId); } - private async Task CreateTokenAsync() - { - string testUserId = Guid.NewGuid().ToString(); - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - db.Users.Add(new EssentialCSharpWebUser - { - Id = testUserId, - UserName = $"mcp-contract-{testUserId[..8]}", - NormalizedUserName = $"MCP-CONTRACT-{testUserId[..8].ToUpperInvariant()}", - Email = $"mcp-contract-{testUserId[..8]}@example.com", - NormalizedEmail = $"MCP-CONTRACT-{testUserId[..8].ToUpperInvariant()}@EXAMPLE.COM", - SecurityStamp = Guid.NewGuid().ToString(), - }); - await db.SaveChangesAsync(); - - var tokenService = scope.ServiceProvider.GetRequiredService(); - (string rawToken, _) = await tokenService.CreateTokenAsync(testUserId, "mcp-contract-test"); - return rawToken; - } - private string GetConfiguredBaseUrl() { string baseUrl = factory.Services.GetRequiredService>().Value.BaseUrl; @@ -254,11 +233,11 @@ private static async Task SendRpcAsync( string? sessionId, string payload) { - var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") { Content = new StringContent(payload, Encoding.UTF8, "application/json") }; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); + McpTestHelper.AddBearerToken(request, rawToken); request.Headers.Accept.ParseAdd("application/json"); request.Headers.Accept.ParseAdd("text/event-stream"); if (sessionId is not null) @@ -300,38 +279,16 @@ private static async Task ReadMcpPayloadAsync(HttpResponseMessage respon private static JsonElement GetTool(JsonElement tools, string toolName) { - foreach (JsonElement tool in tools.EnumerateArray()) + JsonElement tool = tools.EnumerateArray() + .Where(tool => string.Equals(tool.GetProperty("name").GetString(), toolName, StringComparison.Ordinal)) + .FirstOrDefault(); + + if (tool.ValueKind is not JsonValueKind.Undefined) { - if (string.Equals(tool.GetProperty("name").GetString(), toolName, StringComparison.Ordinal)) - { - return tool; - } + return tool; } throw new InvalidOperationException($"Could not find MCP tool '{toolName}' in tools/list response."); } - 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") - }; - request.Headers.Accept.ParseAdd("application/json"); - request.Headers.Accept.ParseAdd("text/event-stream"); - return request; - } } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml index 463ebf15..9759917a 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml @@ -35,16 +35,15 @@
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"
-    }
+                

Add this server entry inside your MCP client's servers or mcpServers section:

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

See the AI Tools setup guide for per-client configuration snippets.

+

This is the inner server entry only. See the AI Tools setup guide for the exact top-level wrapper each client expects.

} diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs index 4a0c2d97..55c58b43 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs @@ -30,6 +30,7 @@ public class McpAccessModel( public async Task OnGetAsync() { + DisableCaching(); string? userId = userManager.GetUserId(User); if (userId is null) return Challenge(); UserTokens = await tokenService.GetUserTokensAsync(userId); @@ -38,6 +39,7 @@ public async Task OnGetAsync() public async Task OnPostCreateAsync() { + DisableCaching(); string? userId = userManager.GetUserId(User); if (userId is null) return Challenge(); @@ -65,6 +67,7 @@ public async Task OnPostCreateAsync() public async Task OnPostRevokeAsync(Guid tokenId) { + DisableCaching(); string? userId = userManager.GetUserId(User); if (userId is null) return Challenge(); @@ -75,4 +78,11 @@ public async Task OnPostRevokeAsync(Guid tokenId) return RedirectToPage(); } + + private void DisableCaching() + { + Response.Headers.CacheControl = "no-store, no-cache, max-age=0"; + Response.Headers.Pragma = "no-cache"; + Response.Headers.Expires = "0"; + } } diff --git a/EssentialCSharp.Web/Auth/McpApiKeyAuthenticationHandler.cs b/EssentialCSharp.Web/Auth/McpApiKeyAuthenticationHandler.cs index cebef627..579da975 100644 --- a/EssentialCSharp.Web/Auth/McpApiKeyAuthenticationHandler.cs +++ b/EssentialCSharp.Web/Auth/McpApiKeyAuthenticationHandler.cs @@ -20,25 +20,35 @@ public class McpApiKeyAuthenticationHandler( { protected override async Task HandleAuthenticateAsync() { - string? authHeader = Request.Headers.Authorization.FirstOrDefault(); - if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + if (!McpBearerAuthentication.TryGetRawToken(Request, out string? rawToken)) + { return AuthenticateResult.NoResult(); + } - string rawToken = authHeader["Bearer ".Length..].Trim(); - if (!rawToken.StartsWith("mcp_", StringComparison.Ordinal)) - return AuthenticateResult.NoResult(); + McpApiTokenService.ResolvedMcpApiToken? resolvedToken; + if (McpBearerAuthentication.TryGetStoredResolution(Context, out resolvedToken)) + { + if (resolvedToken is null) + { + return AuthenticateResult.Fail("Invalid or revoked MCP token."); + } + } + else + { + resolvedToken = await tokenService.ResolveValidTokenAsync(rawToken, Context.RequestAborted); + if (resolvedToken is null) + { + return AuthenticateResult.Fail("Invalid or revoked MCP token."); + } + } - var (token, userId) = await tokenService.ValidateTokenAsync(rawToken, Context.RequestAborted); - if (token is null || userId is null) + if (!await tokenService.MarkTokenUsedAsync(resolvedToken.TokenId, Context.RequestAborted)) + { return AuthenticateResult.Fail("Invalid or revoked MCP token."); + } - var claims = new[] - { - new Claim(ClaimTypes.NameIdentifier, userId), - }; - var identity = new ClaimsIdentity(claims, Scheme.Name); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, Scheme.Name); + ClaimsPrincipal principal = McpBearerAuthentication.CreatePrincipal(resolvedToken.UserId); + var ticket = new AuthenticationTicket(principal, McpBearerAuthentication.Scheme); return AuthenticateResult.Success(ticket); } } diff --git a/EssentialCSharp.Web/Auth/McpBearerAuthentication.cs b/EssentialCSharp.Web/Auth/McpBearerAuthentication.cs new file mode 100644 index 00000000..d3a8d107 --- /dev/null +++ b/EssentialCSharp.Web/Auth/McpBearerAuthentication.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; +using EssentialCSharp.Web.Services; + +namespace EssentialCSharp.Web.Auth; + +internal static class McpBearerAuthentication +{ + internal sealed record ResolutionResult(McpApiTokenService.ResolvedMcpApiToken? Token); + + private static readonly object ResolutionResultKey = new(); + + public const string Scheme = "McpBearer"; + + public static bool TryGetRawToken(HttpRequest request, [NotNullWhen(true)] out string? rawToken) + { + rawToken = null; + + string? authHeader = request.Headers.Authorization.FirstOrDefault(); + if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string candidate = authHeader["Bearer ".Length..].Trim(); + if (!candidate.StartsWith("mcp_", StringComparison.Ordinal)) + { + return false; + } + + rawToken = candidate; + return true; + } + + public static ClaimsPrincipal CreatePrincipal(string userId) => + new(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, userId)], Scheme)); + + public static void StoreResolution(HttpContext context, McpApiTokenService.ResolvedMcpApiToken? resolvedToken) => + context.Items[ResolutionResultKey] = new ResolutionResult(resolvedToken); + + public static bool TryGetStoredResolution( + HttpContext context, + out McpApiTokenService.ResolvedMcpApiToken? resolvedToken) + { + if (context.Items.TryGetValue(ResolutionResultKey, out object? value) + && value is ResolutionResult result) + { + resolvedToken = result.Token; + return true; + } + + resolvedToken = null; + return false; + } +} diff --git a/EssentialCSharp.Web/Controllers/McpTokenController.cs b/EssentialCSharp.Web/Controllers/McpTokenController.cs index 6f3b955e..7409bb3f 100644 --- a/EssentialCSharp.Web/Controllers/McpTokenController.cs +++ b/EssentialCSharp.Web/Controllers/McpTokenController.cs @@ -8,9 +8,10 @@ namespace EssentialCSharp.Web.Controllers; [ApiController] [Route("api/[controller]")] [Authorize] +[ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] public class McpTokenController(McpApiTokenService tokenService) : ControllerBase { - public record CreateTokenRequest(string Name, DateOnly? ExpiresOn = null); + public record CreateTokenRequest(string? Name, DateOnly? ExpiresOn = null); [HttpPost] public async Task CreateToken( diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 66633cdb..0ceb3f20 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using System.Threading.RateLimiting; using ModelContextProtocol.Protocol; using EssentialCSharp.Chat.Common.Extensions; @@ -23,6 +24,7 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using OpenTelemetry; using OpenTelemetry.Instrumentation.AspNetCore; @@ -188,7 +190,8 @@ private static void Main(string[] args) // redirect, eventually hitting the fallback controller and returning a 404. options.Events.OnRedirectToLogin = context => { - if (context.Request.Path.StartsWithSegments("/api")) + if (context.Request.Path.StartsWithSegments("/api") + || context.Request.Path.StartsWithSegments("/mcp")) context.Response.StatusCode = StatusCodes.Status401Unauthorized; else context.Response.Redirect(context.RedirectUri); @@ -196,7 +199,8 @@ private static void Main(string[] args) }; options.Events.OnRedirectToAccessDenied = context => { - if (context.Request.Path.StartsWithSegments("/api")) + if (context.Request.Path.StartsWithSegments("/api") + || context.Request.Path.StartsWithSegments("/mcp")) context.Response.StatusCode = StatusCodes.Status403Forbidden; else context.Response.Redirect(context.RedirectUri); @@ -257,13 +261,23 @@ private static void Main(string[] args) builder.Services.AddAuthentication() .AddScheme( - "McpBearer", _ => { }); + McpBearerAuthentication.Scheme, _ => { }); builder.Services.AddAuthorization(options => options.AddPolicy("McpPolicy", policy => - policy.AddAuthenticationSchemes("McpBearer") + policy.AddAuthenticationSchemes(McpBearerAuthentication.Scheme) .RequireAuthenticatedUser())); + builder.Services.AddCors(options => + options.AddPolicy("McpInspectorCors", policy => + policy.SetIsOriginAllowed(origin => + Uri.TryCreate(origin, UriKind.Absolute, out Uri? originUri) + && originUri.IsLoopback + && (originUri.Scheme == Uri.UriSchemeHttp || originUri.Scheme == Uri.UriSchemeHttps)) + .AllowAnyHeader() + .AllowAnyMethod() + .WithExposedHeaders("Mcp-Session-Id"))); + builder.Services.AddSingleton(); builder.Services.AddMcpServer() @@ -276,14 +290,18 @@ private static void Main(string[] args) // Add Rate Limiting for API endpoints builder.Services.AddRateLimiter(options => { - // Global rate limiter for authenticated users by username, anonymous by IP + // Global rate limiter for site requests by authenticated user ID or anonymous IP. + // MCP transport requests use a dedicated named policy attached to MapMcp("/mcp"). options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => { if (httpContext.Request.Path.StartsWithSegments("/.well-known")) return RateLimitPartition.GetNoLimiter("well-known"); + if (IsMcpTransportRequest(httpContext.Request)) + return RateLimitPartition.GetNoLimiter("mcp-transport"); + var partitionKey = httpContext.User.Identity?.IsAuthenticated == true - ? httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "unknown-user" + ? httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown-user" : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; return RateLimitPartition.GetFixedWindowLimiter( @@ -319,62 +337,49 @@ private static void Main(string[] args) // A scraper cycling through the full ~400-page book needs 2+ hours at minimum. // See Services/ContentRateLimiterPolicy.cs for implementation. options.AddPolicy("content", new ContentRateLimiterPolicy()); + options.AddPolicy(McpRateLimiterPolicy.PolicyName, new McpRateLimiterPolicy()); // Custom response when rate limit is exceeded options.OnRejected = async (context, cancellationToken) => { 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 JsonRpcError - { - JsonRpc = "2.0", - Error = new JsonRpcErrorDetail - { - Code = -32000, - Message = "Rate limit exceeded. Please wait before sending another request." - } - }; - await System.Text.Json.JsonSerializer.SerializeAsync( - context.HttpContext.Response.Body, - mcpErrorResponse, - cancellationToken: cancellationToken); - return; - } + int? retryAfterSeconds = RateLimitingResponseHelpers.ApplyRetryAfterHeader( + context.HttpContext.Response, + context.Lease); + var logger = context.HttpContext.RequestServices.GetRequiredService>(); if (context.HttpContext.Request.Path.StartsWithSegments("/api/chat")) { // Custom rejection handling logic context.HttpContext.Response.ContentType = "application/json"; - var errorResponse = new + Dictionary errorResponse = new() { - error = "Rate limit exceeded. Please wait before sending another message.", - retryAfter = 60, - requiresCaptcha = true, - statusCode = 429 + ["error"] = "Rate limit exceeded. Please wait before sending another message.", + ["requiresCaptcha"] = true, + ["statusCode"] = 429 }; + if (retryAfterSeconds is int retryAfter) + errorResponse["retryAfter"] = retryAfter; await context.HttpContext.Response.WriteAsync( System.Text.Json.JsonSerializer.Serialize(errorResponse), cancellationToken); // Optional logging - initialLogger.LogWarning("Rate limit exceeded on {Path}. User: {User}, IP: {IpAddress}", - context.HttpContext.Request.Path, - context.HttpContext.User.Identity?.Name ?? "anonymous", - context.HttpContext.Connection.RemoteIpAddress); + logger.LogWarning("Rate limit exceeded on {Path}. User: {User}, IP: {IpAddress}", + context.HttpContext.Request.Path, + context.HttpContext.User.Identity?.Name ?? "anonymous", + context.HttpContext.Connection.RemoteIpAddress); return; } await context.HttpContext.Response.WriteAsync("Rate limit exceeded. Please try again later.", cancellationToken); - initialLogger.LogWarning("Rate limit exceeded on {Path}. User: {User}, IP: {IpAddress}", - context.HttpContext.Request.Path, - context.HttpContext.User.Identity?.Name ?? "anonymous", - context.HttpContext.Connection.RemoteIpAddress); + logger.LogWarning("Rate limit exceeded on {Path}. User: {User}, IP: {IpAddress}", + context.HttpContext.Request.Path, + context.HttpContext.User.Identity?.Name ?? "anonymous", + context.HttpContext.Connection.RemoteIpAddress); }; }); @@ -423,7 +428,16 @@ await context.HttpContext.Response.WriteAsync( var logger = context.RequestServices.GetRequiredService>(); logger.LogError(exceptionFeature?.Error, "Unhandled exception on {Path}", context.Request.Path); - if (context.Request.Path.StartsWithSegments("/api")) + if (context.Request.Path.StartsWithSegments("/mcp")) + { + await McpJsonRpcResponseWriter.WriteErrorAsync( + context.Response, + StatusCodes.Status500InternalServerError, + -32603, + "An unexpected error occurred while processing the MCP request.", + context.RequestAborted); + } + else if (context.Request.Path.StartsWithSegments("/api")) { context.Response.StatusCode = 500; context.Response.ContentType = "application/json"; @@ -490,8 +504,35 @@ await context.HttpContext.Response.WriteAsync( app.UseRouting(); + app.UseWhen( + context => context.Request.Path.StartsWithSegments("/mcp"), + branch => branch.UseCors("McpInspectorCors")); + app.UseAuthentication(); + app.UseWhen( + context => context.Request.Path.StartsWithSegments("/mcp"), + branch => branch.Use(async (context, next) => + { + // /mcp uses a named non-default scheme. Normalize the principal before + // rate limiting so valid MCP requests partition by MCP user while + // missing/invalid bearer requests fall back to the anonymous/IP bucket + // instead of inheriting the site's cookie principal. + McpApiTokenService.ResolvedMcpApiToken? resolvedToken = null; + if (McpBearerAuthentication.TryGetRawToken(context.Request, out string? rawToken)) + { + var tokenService = context.RequestServices.GetRequiredService(); + resolvedToken = await tokenService.ResolveValidTokenAsync(rawToken, context.RequestAborted); + McpBearerAuthentication.StoreResolution(context, resolvedToken); + } + + context.User = resolvedToken is not null + ? McpBearerAuthentication.CreatePrincipal(resolvedToken.UserId) + : new ClaimsPrincipal(new ClaimsIdentity()); + + await next(context); + })); + app.UseRateLimiter(); app.UseAuthorization(); @@ -501,7 +542,16 @@ await context.HttpContext.Response.WriteAsync( app.MapRazorPages(); app.MapDefaultControllerRoute(); - app.MapMcp("/mcp").RequireAuthorization("McpPolicy"); + app.MapMethods("/mcp", [HttpMethods.Get], (HttpResponse response) => + { + response.Headers.Append("Allow", HttpMethods.Post); + response.Headers.CacheControl = "no-store"; + return Results.StatusCode(StatusCodes.Status405MethodNotAllowed); + }); + + app.MapMcp("/mcp") + .RequireAuthorization("McpPolicy") + .RequireRateLimiting(McpRateLimiterPolicy.PolicyName); app.MapFallbackToController("Index", "Home"); @@ -530,4 +580,7 @@ await context.HttpContext.Response.WriteAsync( app.Run(); } + + private static bool IsMcpTransportRequest(HttpRequest request) => + HttpMethods.IsPost(request.Method) && request.Path == "/mcp"; } diff --git a/EssentialCSharp.Web/Services/McpApiTokenService.cs b/EssentialCSharp.Web/Services/McpApiTokenService.cs index 4012cfe0..93cef143 100644 --- a/EssentialCSharp.Web/Services/McpApiTokenService.cs +++ b/EssentialCSharp.Web/Services/McpApiTokenService.cs @@ -9,6 +9,8 @@ namespace EssentialCSharp.Web.Services; public class McpApiTokenService(EssentialCSharpWebContext db) { + public sealed record ResolvedMcpApiToken(Guid TokenId, string UserId); + /// Returns SHA-256 hash of the raw token as a byte array (varbinary(32)). public static byte[] HashToken(string rawToken) => SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)); @@ -67,27 +69,35 @@ public Task> GetUserTokensAsync( .ToListAsync(cancellationToken); /// - /// Validates a raw token string. Updates LastUsedAt and UsageCount on success. - /// Returns (token, userId) on success, or (null, null) on failure. + /// Resolves a raw token string to its owning user if the token exists and is currently valid. + /// Does not update LastUsedAt or UsageCount. /// - public async Task<(McpApiToken? Token, string? UserId)> ValidateTokenAsync( + public Task ResolveValidTokenAsync( string rawToken, CancellationToken cancellationToken = default) { byte[] hash = HashToken(rawToken); DateTime now = DateTime.UtcNow; - // Initial read — early exit for completely unknown tokens before hitting the update round-trip - McpApiToken? token = await db.McpApiTokens + return db.McpApiTokens .AsNoTracking() - .FirstOrDefaultAsync(t => t.TokenHash == hash, cancellationToken); - - if (token is null) return (null, null); + .Where(t => t.TokenHash == hash + && t.RevokedAt == null + && (t.ExpiresAt == null || t.ExpiresAt > now)) + .Select(t => new ResolvedMcpApiToken(t.Id, t.UserId)) + .FirstOrDefaultAsync(cancellationToken); + } - // Atomic guard: re-checks validity at the moment of update so a concurrent - // revoke between the read above and this update cannot slip through (TOCTOU fix). + /// + /// Records successful authenticated token usage. Returns false if the token is no longer valid. + /// + public async Task MarkTokenUsedAsync( + Guid tokenId, + CancellationToken cancellationToken = default) + { + DateTime now = DateTime.UtcNow; int rows = await db.McpApiTokens - .Where(t => t.Id == token.Id + .Where(t => t.Id == tokenId && t.RevokedAt == null && (t.ExpiresAt == null || t.ExpiresAt > now)) .ExecuteUpdateAsync(s => s @@ -95,8 +105,6 @@ public Task> GetUserTokensAsync( .SetProperty(t => t.UsageCount, t => t.UsageCount + 1), cancellationToken); - if (rows == 0) return (null, null); - - return (token, token.UserId); + return rows > 0; } } diff --git a/EssentialCSharp.Web/Services/McpJsonRpcResponseWriter.cs b/EssentialCSharp.Web/Services/McpJsonRpcResponseWriter.cs new file mode 100644 index 00000000..8710c102 --- /dev/null +++ b/EssentialCSharp.Web/Services/McpJsonRpcResponseWriter.cs @@ -0,0 +1,34 @@ +using ModelContextProtocol.Protocol; + +namespace EssentialCSharp.Web.Services; + +internal static class McpJsonRpcResponseWriter +{ + public static Task WriteErrorAsync( + HttpResponse response, + int statusCode, + int errorCode, + string message, + CancellationToken cancellationToken) + { + response.StatusCode = statusCode; + response.ContentType = "application/json"; + response.Headers.CacheControl = "no-store"; + + string payload = System.Text.Json.JsonSerializer.Serialize(new McpJsonRpcErrorResponse( + "2.0", + null, + new JsonRpcErrorDetail + { + Code = errorCode, + Message = message + })); + + return response.WriteAsync(payload, cancellationToken); + } + + private sealed record McpJsonRpcErrorResponse( + [property: System.Text.Json.Serialization.JsonPropertyName("jsonrpc")] string JsonRpc, + [property: System.Text.Json.Serialization.JsonPropertyName("id")] object? Id, + [property: System.Text.Json.Serialization.JsonPropertyName("error")] JsonRpcErrorDetail Error); +} diff --git a/EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs b/EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs new file mode 100644 index 00000000..7f2d9ee6 --- /dev/null +++ b/EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs @@ -0,0 +1,70 @@ +using System.Security.Claims; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; + +namespace EssentialCSharp.Web.Services; + +internal sealed class McpRateLimiterPolicy : IRateLimiterPolicy +{ + internal const string PolicyName = "mcp"; + internal const int AuthenticatedTokenLimit = 45; + internal const int AuthenticatedTokensPerPeriod = 1; + internal static readonly TimeSpan AuthenticatedReplenishmentPeriod = TimeSpan.FromSeconds(2); + internal const int AnonymousPermitLimit = 30; + internal static readonly TimeSpan AnonymousWindow = TimeSpan.FromMinutes(1); + + public Func? OnRejected => OnRejectedAsync; + + public RateLimitPartition GetPartition(HttpContext httpContext) + { + if (httpContext.User.Identity?.IsAuthenticated == true) + { + string userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? httpContext.User.Identity?.Name + ?? "unknown-user"; + + return RateLimitPartition.GetTokenBucketLimiter( + partitionKey: $"mcp-user:{userId}", + factory: _ => new TokenBucketRateLimiterOptions + { + AutoReplenishment = true, + TokenLimit = AuthenticatedTokenLimit, + TokensPerPeriod = AuthenticatedTokensPerPeriod, + ReplenishmentPeriod = AuthenticatedReplenishmentPeriod, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0 + }); + } + + string partitionKey = $"mcp-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}"; + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: partitionKey, + factory: _ => new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = AnonymousPermitLimit, + Window = AnonymousWindow, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0 + }); + } + + private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken) + { + RateLimitingResponseHelpers.ApplyRetryAfterHeader(context.HttpContext.Response, context.Lease); + await McpJsonRpcResponseWriter.WriteErrorAsync( + context.HttpContext.Response, + StatusCodes.Status429TooManyRequests, + -32000, + "Rate limit exceeded. Please wait before sending another request.", + cancellationToken); + + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogWarning( + "MCP rate limit exceeded on {Path}. User: {User}, IP: {IpAddress}", + context.HttpContext.Request.Path, + context.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous", + context.HttpContext.Connection.RemoteIpAddress); + } +} diff --git a/EssentialCSharp.Web/Services/RateLimitingResponseHelpers.cs b/EssentialCSharp.Web/Services/RateLimitingResponseHelpers.cs new file mode 100644 index 00000000..5efe3fd2 --- /dev/null +++ b/EssentialCSharp.Web/Services/RateLimitingResponseHelpers.cs @@ -0,0 +1,19 @@ +using System.Globalization; +using System.Threading.RateLimiting; + +namespace EssentialCSharp.Web.Services; + +internal static class RateLimitingResponseHelpers +{ + public static int? ApplyRetryAfterHeader(HttpResponse response, RateLimitLease lease) + { + if (!lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter)) + { + return null; + } + + int retryAfterSeconds = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)); + response.Headers.RetryAfter = retryAfterSeconds.ToString(CultureInfo.InvariantCulture); + return retryAfterSeconds; + } +}