diff --git a/Directory.Packages.props b/Directory.Packages.props index f10bbf99..24095d49 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -50,8 +50,9 @@ - - + + + diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index 8dfab8c4..984cb455 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. @@ -234,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. @@ -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,78 @@ 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) + { + 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, + _ => (object?)jsonElement.ToString() + } + : kvp.Value; + } + + var toolResult = await mcpClient.CallToolAsync( + functionCallItem.FunctionName, + arguments: arguments, + cancellationToken: cancellationToken); + + responseItems.Add(functionCallItem); + responseItems.Add(new FunctionCallOutputResponseItem( + functionCallItem.CallId, + McpToolResultFormatter.GetModelInput(toolResult))); + } + 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.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.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/McpTests.cs b/EssentialCSharp.Web.Tests/McpTests.cs new file mode 100644 index 00000000..d42f3f4c --- /dev/null +++ b/EssentialCSharp.Web.Tests/McpTests.cs @@ -0,0 +1,212 @@ +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; + +namespace EssentialCSharp.Web.Tests; + +[NotInParallel("McpTests")] +[ClassDataSource(Shared = SharedType.PerClass)] +public class McpTests(WebApplicationFactory factory) +{ + [Test] + public async Task McpTokenEndpoint_WithoutAuth_Returns401() + { + HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + using HttpResponseMessage response = await client.PostAsync("/api/McpToken", null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task McpEndpoint_WithoutToken_Returns401() + { + HttpClient client = factory.CreateClient(); + + using var request = CreateMcpInitializeRequest("/mcp"); + using HttpResponseMessage response = await client.SendAsync(request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [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"); + } + + HttpClient client = factory.CreateClient(); + + // Step 1: Initialize the MCP session + 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); + + // 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 + using 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", rawToken); + listToolsRequest.Headers.Accept.ParseAdd("application/json"); + listToolsRequest.Headers.Accept.ParseAdd("text/event-stream"); + if (sessionId is not null) + listToolsRequest.Headers.Add("Mcp-Session-Id", sessionId); + + using HttpResponseMessage toolsResponse = await client.SendAsync( + listToolsRequest, HttpCompletionOption.ResponseHeadersRead); + await Assert.That(toolsResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // 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)); + var body = new StringBuilder(); + string? line; + while ((line = await reader.ReadLineAsync(cts.Token)) is not null) + { + body.AppendLine(line); + if (body.ToString().Contains("search_book_content") && + body.ToString().Contains("get_chapter_list")) + break; + } + + 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(); + 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); + } + + [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(); + 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); + } + + [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(); + 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); + } + + 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/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/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/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..463ebf15 --- /dev/null +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml @@ -0,0 +1,152 @@ +@page +@model McpAccessModel +@using EssentialCSharp.Web.Models +@{ + 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. 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) + { +
+
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
+
+

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"
+    }
+  }
+}
+

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

+
+
+ } + +
+
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.
+
+ +
+
+
+ + @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) + { +
+ + +
+ } +
+
+
+ } +
+ +@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..4a0c2d97 --- /dev/null +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs @@ -0,0 +1,78 @@ +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; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage; + +public class McpAccessModel( + McpApiTokenService tokenService, + UserManager userManager) : PageModel +{ + [TempData] + public string? StatusMessage { get; set; } + + public string? GeneratedToken { get; private set; } + + public McpApiToken? GeneratedTokenEntity { get; private set; } + + public List UserTokens { get; private set; } = []; + + [BindProperty] + [StringLength(256, ErrorMessage = "Token name must be 256 characters or fewer.")] + public string TokenName { get; set; } = "My Token"; + + [BindProperty] + public DateOnly? ExpiresOn { get; set; } + + public async Task OnGetAsync() + { + string? userId = userManager.GetUserId(User); + if (userId is null) return Challenge(); + UserTokens = await tokenService.GetUserTokensAsync(userId); + return Page(); + } + + public async Task OnPostCreateAsync() + { + 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) + { + UserTokens = await tokenService.GetUserTokensAsync(userId); + return Page(); + } + + // Convert date-only boundary to end-of-day UTC instant before persisting + DateTime? expiresAt = ExpiresOn?.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); + + var (rawToken, entity) = await tokenService.CreateTokenAsync(userId, TokenName.Trim(), expiresAt); + GeneratedToken = rawToken; + GeneratedTokenEntity = entity; + UserTokens = await tokenService.GetUserTokensAsync(userId); + return Page(); + } + + public async Task OnPostRevokeAsync(Guid tokenId) + { + string? userId = userManager.GetUserId(User); + if (userId is null) return Challenge(); + + 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/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/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/McpSetupController.cs b/EssentialCSharp.Web/Controllers/McpSetupController.cs new file mode 100644 index 00000000..36efc731 --- /dev/null +++ b/EssentialCSharp.Web/Controllers/McpSetupController.cs @@ -0,0 +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 +{ + 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"; + 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/Controllers/McpTokenController.cs b/EssentialCSharp.Web/Controllers/McpTokenController.cs new file mode 100644 index 00000000..6f3b955e --- /dev/null +++ b/EssentialCSharp.Web/Controllers/McpTokenController.cs @@ -0,0 +1,64 @@ +using System.Security.Claims; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace EssentialCSharp.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class McpTokenController(McpApiTokenService tokenService) : ControllerBase +{ + public record CreateTokenRequest(string Name, DateOnly? ExpiresOn = null); + + [HttpPost] + public async Task CreateToken( + [FromBody] CreateTokenRequest? request, + CancellationToken cancellationToken) + { + 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 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 is DateOnly expiresOn) + { + 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 = expiresOn.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); + } + + var (rawToken, entity) = await tokenService.CreateTokenAsync( + userId, name, expiresAt, cancellationToken); + + return Ok(new + { + 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/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/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/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/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 ba88dc5a..66633cdb 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -1,14 +1,18 @@ 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; +using EssentialCSharp.Web.Auth; using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Extensions; using EssentialCSharp.Web.Helpers; using EssentialCSharp.Web.Middleware; using EssentialCSharp.Web.Services; using EssentialCSharp.Web.Services.Referrals; +using EssentialCSharp.Web.Tools; using Mailjet.Client; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; @@ -19,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; @@ -230,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(); @@ -237,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 @@ -245,14 +252,38 @@ private static void Main(string[] args) builder.Services.AddAzureOpenAIServices(configuration); } + // MCP server — always enabled, authenticated via opaque DB-backed tokens. + builder.Services.AddScoped(); + + builder.Services.AddAuthentication() + .AddScheme( + "McpBearer", _ => { }); + + builder.Services.AddAuthorization(options => + options.AddPolicy("McpPolicy", policy => + policy.AddAuthenticationSchemes("McpBearer") + .RequireAuthenticatedUser())); + + builder.Services.AddSingleton(); + + builder.Services.AddMcpServer() + .WithHttpTransport(options => options.Stateless = true) + .WithTools() + .WithTools() + .WithTools() + .WithTools(); + // 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.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "unknown-user" : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; return RateLimitPartition.GetFixedWindowLimiter( @@ -292,12 +323,27 @@ 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 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; } - 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 +491,18 @@ await context.HttpContext.Response.WriteAsync( app.UseRouting(); app.UseAuthentication(); - app.UseAuthorization(); app.UseRateLimiter(); + app.UseAuthorization(); + app.UseMiddleware(); app.MapRazorPages(); app.MapDefaultControllerRoute(); + app.MapMcp("/mcp").RequireAuthorization("McpPolicy"); + app.MapFallbackToController("Index", "Home"); // Generate sitemap.xml at startup @@ -462,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/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/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/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/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/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/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/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 new file mode 100644 index 00000000..419cc51b --- /dev/null +++ b/EssentialCSharp.Web/Tools/BookContentTool.cs @@ -0,0 +1,153 @@ +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.Server; + +namespace EssentialCSharp.Web.Tools; + +[McpServerToolType] +public sealed partial class BookContentTool +{ + private readonly ISiteMappingService _siteMappingService; + private readonly IBookToolQueryService _bookToolQueryService; + private readonly IListingSourceCodeService _listingService; + private readonly IWebHostEnvironment _environment; + private readonly AISearchService? _searchService; + + public BookContentTool( + ISiteMappingService siteMappingService, + IBookToolQueryService bookToolQueryService, + IListingSourceCodeService listingService, + IWebHostEnvironment environment, + IServiceProvider serviceProvider) + { + _siteMappingService = siteMappingService; + _bookToolQueryService = bookToolQueryService; + _listingService = listingService; + _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 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). 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 || 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 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}'."; + } + + 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), + 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 = response.FileExtension == "cs" ? "csharp" : response.FileExtension; + List explanations = []; + + 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) + { + foreach (var result in contextResults.Take(3)) + { + explanations.Add(new RelatedBookExplanationTextResult( + result.Record.Heading, + result.Record.ChapterNumber, + result.Record.ChunkText)); + } + } + } + + 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, 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 NavigationContextToolResult GetNavigationContext( + [Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections to get valid slugs.")] string sectionKey) => + _bookToolQueryService.GetNavigationContext(sectionKey); + + [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 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 new file mode 100644 index 00000000..6784c79a --- /dev/null +++ b/EssentialCSharp.Web/Tools/BookGuidelinesTool.cs @@ -0,0 +1,114 @@ +using System.ComponentModel; +using EssentialCSharp.Web.Extensions; +using EssentialCSharp.Web.Models; +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. 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 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("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 (!string.IsNullOrWhiteSpace(type) && typeFilter is null) + { + return "Invalid guideline type. Valid values: 'do', 'consider', 'avoid', 'donot' (also accepts 'do not', 'dont')."; + } + + IEnumerable filtered = _guidelinesService.Guidelines; + + if (chapter is int chapterValue) + filtered = filtered.Where(g => g.ChapterNumber == chapterValue); + + if (typeFilter is GuidelineType typeFilterValue) + filtered = filtered.Where(g => g.Type == typeFilterValue); + + 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)); + + if (!string.IsNullOrWhiteSpace(topic)) + { + 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}'."; + } + + 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 new GuidelinesTextResult(topic, topicResults).ToMcpString(); + } + + var results = filtered.Take(maxResults).ToList(); + + if (results.Count == 0) + { + return "No guidelines found matching the specified filters."; + } + + List guidelineResults = results + .Select(g => new TextGuidelineResult( + g.Type.ToDisplayString(), + g.Guideline, + g.ChapterNumber, + g.ChapterTitle ?? string.Empty, + g.SanitizedSubsection)) + .ToList(); + + return new GuidelinesTextResult(null, guidelineResults).ToMcpString(); + } + + private static GuidelineType? ParseGuidelineType(string? input) + { + if (string.IsNullOrWhiteSpace(input)) return null; + + return input.Trim().ToLowerInvariant().Replace(" ", "").Replace("_", "").Replace("'", "") switch + { + "do" => GuidelineType.Do, + "consider" => GuidelineType.Consider, + "avoid" => GuidelineType.Avoid, + "donot" or "dont" or "donotdo" => GuidelineType.DoNot, + _ => null + }; + } +} diff --git a/EssentialCSharp.Web/Tools/BookListingTool.cs b/EssentialCSharp.Web/Tools/BookListingTool.cs new file mode 100644 index 00000000..60795207 --- /dev/null +++ b/EssentialCSharp.Web/Tools/BookListingTool.cs @@ -0,0 +1,114 @@ +using System.ComponentModel; +using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; +using ModelContextProtocol.Protocol; +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."; + } + + 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, + 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( + [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 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 McpToolResultFactory.CreateError("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 + .Select(m => m.ChapterNumber) + .Distinct() + .OrderBy(n => n); + + List matches = []; + + foreach (int chapterNumber in distinctChapters) + { + if (matches.Count >= maxResults) break; + cancellationToken.ThrowIfCancellationRequested(); + + var listings = await _listingService.GetListingsByChapterAsync(chapterNumber); + foreach (var listing in listings) + { + if (matches.Count >= maxResults) break; + if (listing.Content.Contains(trimmedPattern, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(new ListingSourceCodeResult( + listing.ChapterNumber, + listing.ListingNumber, + ToLanguageHint(listing.FileExtension), + listing.Content)); + } + } + } + + ListingSearchToolResult structuredResult = new(trimmedPattern, matches); + if (matches.Count == 0) + { + return McpToolResultFactory.CreateHybridResult( + $"No listings found containing '{trimmedPattern}'.", + structuredResult); + } + + 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 new file mode 100644 index 00000000..85a9fcab --- /dev/null +++ b/EssentialCSharp.Web/Tools/BookSearchTool.cs @@ -0,0 +1,400 @@ +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; + +[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, + IBookToolQueryService bookToolQueryService) + { + _SearchService = serviceProvider.GetService(); + _SiteMappingService = siteMappingService; + _guidelinesService = guidelinesService; + _bookToolQueryService = bookToolQueryService; + } + + [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, + [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)) + { + 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)."; + } + + 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 (matches.Count == 0) + { + return "No results found for the given query."; + } + + return new SearchBookContentTextResult(matches).ToMcpString(); + } + + [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 ChapterListToolResult GetChapterList() => _bookToolQueryService.GetChapterList(); + + [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 ChapterSectionsToolResult GetChapterSections( + [Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) => + _bookToolQueryService.GetChapterSections(chapter); + + [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.")] + 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).")] int maxResults = AISearchService.DefaultSearchTop, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(concept)) + { + 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 + .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(); + + 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) + { + string heading = r.Record.Heading ?? ""; + if (!seen.Add(heading)) + { + continue; + } + + semanticMatches.Add(new SemanticBookContentMatchTextResult( + r.Record.ChapterNumber ?? 0, + heading, + r.Record.ChunkText[..Math.Min(200, r.Record.ChunkText.Length)])); + } + } + + 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 new LookupConceptTextResult( + concept, + headingMatches.Select(ToSectionLink).ToList(), + semanticMatches).ToMcpString(); + } + + [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."; + } + if (topic.Length > 500) + { + return "Topic is too long (maximum 500 characters)."; + } + + // 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; + } + + string assessment; + if (hasHeadingCoverage) + { + assessment = "**Comprehensive** — dedicated section headings found"; + } + else if (hasSemanticCoverage) + { + assessment = "**Mentioned** — referenced in book content but no dedicated section heading"; + } + else + { + 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"; + } + + 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, + 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( + [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 McpToolResultFactory.CreateError("Diagnostic must not be empty."); + } + + string trimmedDiagnostic = diagnostic.Trim(); + if (trimmedDiagnostic.Length > 500) + { + return McpToolResultFactory.CreateError("Diagnostic is too long (maximum 500 characters)."); + } + + string searchTerm = MapDiagnosticToTopic(trimmedDiagnostic); + + // Heading search + var headingMatches = _SiteMappingService.SiteMappings + .Where(m => m.RawHeading.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) + .Take(5) + .ToList(); + + List contentMatches = []; + if (_SearchService is not null) + { + var vectorResults = await _SearchService.ExecuteVectorSearch(searchTerm, cancellationToken: cancellationToken); + foreach (var r in vectorResults.Take(3)) + { + contentMatches.Add(new BookContentExcerptResult( + r.Record.ChapterNumber, + r.Record.Heading, + r.Record.ChunkText)); + } + } + + // Guidelines search + var guidelineMatches = _guidelinesService.Guidelines + .Where(g => g.Guideline.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) + || g.SanitizedSubsection.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) + .Take(3) + .ToList(); + + 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 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); + } + + 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), + 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).")] int maxResults = AISearchService.DefaultSearchTop, + 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) + { + 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, top: maxResults, cancellationToken: cancellationToken); + + List relatedSections = []; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { mapping.RawHeading }; + foreach (var r in results) + { + string heading = r.Record.Heading ?? ""; + if (!seen.Add(heading)) continue; + if (relatedSections.Count >= maxResults) 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}"; + + relatedSections.Add(new RelatedSectionMatchTextResult( + heading, + link, + r.Record.ChunkText[..Math.Min(200, r.Record.ChunkText.Length)])); + } + + 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 + ["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; + } +} diff --git a/EssentialCSharp.Web/Views/McpSetup/Index.cshtml b/EssentialCSharp.Web/Views/McpSetup/Index.cshtml new file mode 100644 index 00000000..f2472b9e --- /dev/null +++ b/EssentialCSharp.Web/Views/McpSetup/Index.cshtml @@ -0,0 +1,420 @@ +@using Microsoft.AspNetCore.Identity +@using EssentialCSharp.Web.Areas.Identity.Data +@using EssentialCSharp.Web.Controllers +@model IReadOnlyList +@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 @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
+ } +
+ } +
+
+ } + +
+ + +
+

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 @@ + 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