From 384303e8962cc8b454d2b51a9dc2a757e94f7088 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 9 Aug 2026 18:14:40 +0300 Subject: [PATCH 01/13] Add selectable MCP server capabilities and tool filters Let MCP server hosts choose which capabilities (tools, prompts, resources) are exposed and which tools are listed/invokable via a new optional configuration delegate on WithCrestAppsHandlers. - adds CrestAppsMcpHandlerBuilder with WithoutTools/WithoutSdkTools/ WithoutPrompts/WithoutResources and tool filters (WithToolsInCategory, WithToolsForPurpose, WithToolNames, FilterTools) - applies tool filters to both the list and call handlers so a filtered-out tool can neither be discovered nor invoked - keeps the parameterless WithCrestAppsHandlers() behavior unchanged (all capabilities, all non-hidden tools), so the change is additive and backward compatible with no breaking changes - adds tests and updates the MCP server docs and the 1.1.0 changelog Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/changelog/1.1.0.md | 13 + src/CrestApps.Core.Docs/docs/mcp/server.md | 38 +++ .../CrestAppsMcpHandlerBuilder.cs | 177 +++++++++++ .../McpServerBuilderExtensions.cs | 214 +++++++------ .../Mcp/McpServerBuilderExtensionsTests.cs | 282 +++++++++++++++++- 5 files changed, 636 insertions(+), 88 deletions(-) create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index 38f47143..58c3056c 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -19,6 +19,19 @@ page will be updated as changes land after 1.0.0. - upgrades the framework's dependency baseline, including YesSql 6.0 (new `ISession.SaveAsync` signature), the Model Context Protocol 2.0 packages, the GitHub Copilot SDK 1.0.8 (new `PermissionsApi.SetAllowAllAsync` mode-based API), Anthropic 12.39.0, OllamaSharp 5.4.30, the .NET 10.0.10 runtime/extension packages, and the `Microsoft.Extensions.AI` 10.8.3 packages +## MCP server capability and tool selection + +- adds an optional configuration delegate to `WithCrestAppsHandlers(...)` so an MCP server host can + choose which capabilities to expose and which tools to list and invoke. The new + `CrestAppsMcpHandlerBuilder` provides `WithoutTools()`, `WithoutSdkTools()`, `WithoutPrompts()`, + `WithoutResources()`, and tool filters (`WithToolsInCategory`, `WithToolsForPurpose`, `WithToolNames`, + and `FilterTools`). This makes it possible to expose a read-only knowledgebase server (prompts and + resources only) or a server that only exposes a specific category/purpose/allow-list of tools. Tool + filters apply to both the list and call handlers, so a filtered-out tool can neither be discovered nor + invoked, and multiple filters are AND-combined while values within a single call are OR-combined. This + change is additive and backward compatible: the existing parameterless `WithCrestAppsHandlers()` still + registers every capability and exposes all non-hidden tools + ## Fixes - fixes post-session processing endlessly retrying and eventually failing when the AI returned a successful (HTTP 200) response that could not be parsed into structured task results. The no-tools structured output path now records a `Failed` result with a diagnostic message instead of silently returning no result, so these responses no longer exhaust all retry attempts. The unparseable-response case is now logged at `Warning` (including a preview of the raw AI response) instead of only at `Debug`, and the recorded task error message now explains that the AI produced no parseable result or there was no content to evaluate. diff --git a/src/CrestApps.Core.Docs/docs/mcp/server.md b/src/CrestApps.Core.Docs/docs/mcp/server.md index b8ff90cd..57f7c901 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/server.md +++ b/src/CrestApps.Core.Docs/docs/mcp/server.md @@ -306,6 +306,44 @@ When your application acts as an MCP server, registered AI tools are exposed to Tools registered via `AddCoreAITool()` (see [Custom Tools](../core/tools.md)) are automatically available to MCP clients unless they are marked with `.Hidden()`. Hidden tools remain available to explicitly configured profiles and agents, but the shared MCP handlers do not list or invoke them directly. +### Selecting which capabilities to expose + +`WithCrestAppsHandlers()` accepts an optional configuration delegate so a host can choose which capabilities (tools, prompts, resources) are wired in, and which tools are exposed. With no delegate, every capability is registered and all non-hidden tools are exposed — the same behavior as before. + +```csharp +// Read-only knowledgebase server: prompts + resources, no tools +_ = builder.Services.AddMcpServer() + .WithHttpTransport() + .WithCrestAppsHandlers(handlers => handlers.WithoutTools()); + +// Expose only tools in a category, or with a given purpose +_ = builder.Services.AddMcpServer() + .WithHttpTransport() + .WithCrestAppsHandlers(handlers => handlers + .WithToolsInCategory("knowledgebase") + .WithToolsForPurpose(AIToolPurposes.DataSourceSearch)); + +// Expose an explicit allow-list of tools by registered name +_ = builder.Services.AddMcpServer() + .WithHttpTransport() + .WithCrestAppsHandlers(handlers => handlers.WithToolNames("search_documents")); +``` + +The `CrestAppsMcpHandlerBuilder` exposes: + +| Method | Effect | +|--------|--------| +| `WithoutTools()` | Does not register the tool list/call handlers, so the server exposes no tools. | +| `WithoutSdkTools()` | Excludes SDK `McpServerTool` instances while keeping CrestApps tool handlers. | +| `WithoutPrompts()` | Does not register the prompt handlers. | +| `WithoutResources()` | Does not register the resource handlers. | +| `WithToolsInCategory(params string[])` | Exposes only tools assigned to one of the categories. | +| `WithToolsForPurpose(params string[])` | Exposes only tools tagged with one of the purposes. | +| `WithToolNames(params string[])` | Exposes only tools whose registered name matches. | +| `FilterTools(Func)` | Exposes only tools matching a custom predicate. | + +Tool filters are applied to **both** the list and call handlers, so a filtered-out tool can neither be discovered nor invoked. Multiple filters are combined with logical AND (a tool must satisfy every filter); values passed within a single call are combined with logical OR. `.Hidden()` tools are always excluded regardless of filters. + ## Server Metadata ### IMcpServerMetadataProvider diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs b/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs new file mode 100644 index 00000000..c6c0394e --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs @@ -0,0 +1,177 @@ +using CrestApps.Core.AI.Tooling; + +namespace CrestApps.Core.AI.Mcp; + +/// +/// Configures which CrestApps MCP protocol handlers are wired into an MCP server and, +/// for tools, which registered tools are exposed to external clients. By default every +/// capability (tools, prompts, and resources) is registered and all non-hidden tools are +/// exposed. Use the fluent methods to opt out of a capability or to narrow the set of tools. +/// +public sealed class CrestAppsMcpHandlerBuilder +{ + private List> _toolFilters; + + /// + /// Gets a value indicating whether the tool list and call handlers are registered. + /// + public bool IncludeTools { get; private set; } = true; + + /// + /// Gets a value indicating whether SDK McpServerTool instances registered in the + /// service provider are merged into the exposed tool set. This only applies when + /// is . + /// + public bool IncludeSdkTools { get; private set; } = true; + + /// + /// Gets a value indicating whether the prompt list and get handlers are registered. + /// + public bool IncludePrompts { get; private set; } = true; + + /// + /// Gets a value indicating whether the resource list, template list, and read handlers are registered. + /// + public bool IncludeResources { get; private set; } = true; + + /// + /// Excludes the tool handlers so the server does not list or invoke any tools. Use this to expose + /// a read-only knowledgebase server that only serves prompts and resources. + /// + /// The same builder instance for chaining. + public CrestAppsMcpHandlerBuilder WithoutTools() + { + IncludeTools = false; + + return this; + } + + /// + /// Excludes SDK McpServerTool instances from the exposed tool set while keeping the + /// CrestApps tool handlers registered. + /// + /// The same builder instance for chaining. + public CrestAppsMcpHandlerBuilder WithoutSdkTools() + { + IncludeSdkTools = false; + + return this; + } + + /// + /// Excludes the prompt handlers so the server does not list or serve prompts. + /// + /// The same builder instance for chaining. + public CrestAppsMcpHandlerBuilder WithoutPrompts() + { + IncludePrompts = false; + + return this; + } + + /// + /// Excludes the resource handlers so the server does not list, template, or read resources. + /// + /// The same builder instance for chaining. + public CrestAppsMcpHandlerBuilder WithoutResources() + { + IncludeResources = false; + + return this; + } + + /// + /// Restricts the exposed tools to those matching the supplied predicate. Multiple filters are + /// combined with logical AND, so a tool must satisfy every configured filter to be exposed. + /// + /// The predicate evaluated against each registered tool definition. + /// The same builder instance for chaining. + public CrestAppsMcpHandlerBuilder FilterTools(Func predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + + AddToolFilter((_, entry) => predicate(entry)); + + return this; + } + + /// + /// Restricts the exposed tools to those assigned to one of the supplied categories. Multiple + /// filters are combined with logical AND; the categories within this single call are combined + /// with logical OR. + /// + /// The categories to expose. + /// The same builder instance for chaining. + public CrestAppsMcpHandlerBuilder WithToolsInCategory(params string[] categories) + { + ArgumentNullException.ThrowIfNull(categories); + + AddToolFilter((_, entry) => entry.Category is not null + && Array.Exists(categories, category => string.Equals(category, entry.Category, StringComparison.OrdinalIgnoreCase))); + + return this; + } + + /// + /// Restricts the exposed tools to those tagged with one of the supplied purposes. Multiple + /// filters are combined with logical AND; the purposes within this single call are combined + /// with logical OR. Use well-known constants from or custom strings. + /// + /// The purposes to expose. + /// The same builder instance for chaining. + public CrestAppsMcpHandlerBuilder WithToolsForPurpose(params string[] purposes) + { + ArgumentNullException.ThrowIfNull(purposes); + + AddToolFilter((_, entry) => Array.Exists(purposes, purpose => !string.IsNullOrEmpty(purpose) && entry.HasPurpose(purpose))); + + return this; + } + + /// + /// Restricts the exposed tools to those whose registered name matches one of the supplied names. + /// Multiple filters are combined with logical AND; the names within this single call are combined + /// with logical OR. Matching is ordinal and case-insensitive. + /// + /// The registered tool names to expose. + /// The same builder instance for chaining. + public CrestAppsMcpHandlerBuilder WithToolNames(params string[] names) + { + ArgumentNullException.ThrowIfNull(names); + + AddToolFilter((name, entry) => Array.Exists(names, candidate => + string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase) + || (entry.Name is not null && string.Equals(candidate, entry.Name, StringComparison.OrdinalIgnoreCase)))); + + return this; + } + + /// + /// Determines whether the tool with the supplied name and definition passes every configured filter. + /// + /// The registered tool name (the tool definition dictionary key). + /// The tool definition being evaluated. + /// when the tool should be exposed; otherwise . + internal bool IsToolAllowed(string name, AIToolDefinitionEntry entry) + { + if (_toolFilters is null) + { + return true; + } + + foreach (var filter in _toolFilters) + { + if (!filter(name, entry)) + { + return false; + } + } + + return true; + } + + private void AddToolFilter(Func filter) + { + (_toolFilters ??= []).Add(filter); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs index 010a32e8..019b445c 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs @@ -23,37 +23,61 @@ public static class McpServerBuilderExtensions /// /// The builder. public static IMcpServerBuilder WithCrestAppsHandlers(this IMcpServerBuilder builder) + { + return builder.WithCrestAppsHandlers(configure: null); + } + + /// + /// Registers the CrestApps MCP server handlers, letting the caller choose which capabilities + /// (tools, prompts, and resources) are exposed and which tools are listed and invokable. When + /// is every capability is registered and all + /// non-hidden tools are exposed, preserving the previous default behavior. + /// + /// The builder. + /// A delegate that configures the exposed capabilities and tool filters. + public static IMcpServerBuilder WithCrestAppsHandlers( + this IMcpServerBuilder builder, + Action configure) { ArgumentNullException.ThrowIfNull(builder); - return builder - .WithListToolsHandler((request, cancellationToken) => - { - var toolDefinitions = request.Services.GetRequiredService>().Value; - ILogger logger = null; - var tools = new List(); + var handlerBuilder = new CrestAppsMcpHandlerBuilder(); + configure?.Invoke(handlerBuilder); + + if (handlerBuilder.IncludeTools) + { + var includeSdkTools = handlerBuilder.IncludeSdkTools; + + builder + .WithListToolsHandler((request, cancellationToken) => + { + var toolDefinitions = request.Services.GetRequiredService>().Value; + ILogger logger = null; + var tools = new List(); - foreach (var (name, _) in toolDefinitions.Tools.Where(tool => !tool.Value.Hidden)) + foreach (var (name, _) in toolDefinitions.Tools.Where(tool => !tool.Value.Hidden && handlerBuilder.IsToolAllowed(tool.Key, tool.Value))) + { + try { - try + if (request.Services.GetKeyedService(name) is AIFunction aiFunction) { - if (request.Services.GetKeyedService(name) is AIFunction aiFunction) + tools.Add(new Tool { - tools.Add(new Tool - { - Name = aiFunction.Name, - Description = aiFunction.Description, - InputSchema = aiFunction.JsonSchema, - }); - } - } - catch (Exception ex) - { - logger ??= request.Services.GetRequiredService>(); - logger.LogError(ex, "Error creating tool instance for '{ToolName}'.", name); + Name = aiFunction.Name, + Description = aiFunction.Description, + InputSchema = aiFunction.JsonSchema, + }); } } + catch (Exception ex) + { + logger ??= request.Services.GetRequiredService>(); + logger.LogError(ex, "Error creating tool instance for '{ToolName}'.", name); + } + } + if (includeSdkTools) + { var sdkTools = request.Services.GetService>(); if (sdkTools is not null) @@ -81,46 +105,50 @@ public static IMcpServerBuilder WithCrestAppsHandlers(this IMcpServerBuilder bui while (sdkToolEnumerator.MoveNext()); } } + } - return ValueTask.FromResult(new ListToolsResult { Tools = tools }); - }) - .WithCallToolHandler(async (request, cancellationToken) => - { - var toolDefinitions = request.Services.GetRequiredService>().Value; + return ValueTask.FromResult(new ListToolsResult { Tools = tools }); + }) + .WithCallToolHandler(async (request, cancellationToken) => + { + var toolDefinitions = request.Services.GetRequiredService>().Value; - if (toolDefinitions.Tools.TryGetValue(request.Params.Name, out var definition) && - !definition.Hidden) + if (toolDefinitions.Tools.TryGetValue(request.Params.Name, out var definition) && + !definition.Hidden && + handlerBuilder.IsToolAllowed(request.Params.Name, definition)) + { + if (request.Services.GetKeyedService(request.Params.Name) is not AIFunction aiFunction) { - if (request.Services.GetKeyedService(request.Params.Name) is not AIFunction aiFunction) - { - throw new McpException($"Failed to create tool '{request.Params.Name}'."); - } + throw new McpException($"Failed to create tool '{request.Params.Name}'."); + } - var arguments = new AIFunctionArguments + var arguments = new AIFunctionArguments + { + Services = request.Services, + Context = new Dictionary { - Services = request.Services, - Context = new Dictionary - { - ["mcpRequest"] = request, - }, - }; + ["mcpRequest"] = request, + }, + }; - if (request.Params.Arguments is not null) + if (request.Params.Arguments is not null) + { + foreach (var kvp in request.Params.Arguments) { - foreach (var kvp in request.Params.Arguments) - { - arguments[kvp.Key] = kvp.Value; - } + arguments[kvp.Key] = kvp.Value; } + } - var result = await aiFunction.InvokeAsync(arguments, cancellationToken); + var result = await aiFunction.InvokeAsync(arguments, cancellationToken); - return new CallToolResult - { - Content = [new TextContentBlock { Text = result?.ToString() ?? string.Empty }], - }; - } + return new CallToolResult + { + Content = [new TextContentBlock { Text = result?.ToString() ?? string.Empty }], + }; + } + if (includeSdkTools) + { var sdkTools = request.Services.GetService>(); var sdkTool = sdkTools?.FirstOrDefault(t => t.ProtocolTool.Name == request.Params.Name); @@ -128,47 +156,61 @@ public static IMcpServerBuilder WithCrestAppsHandlers(this IMcpServerBuilder bui { return await sdkTool.InvokeAsync(request, cancellationToken); } + } - throw new McpException($"Tool '{request.Params.Name}' not found."); - }) - .WithListPromptsHandler(async (request, cancellationToken) => - { - var promptService = request.Services.GetRequiredService(); + throw new McpException($"Tool '{request.Params.Name}' not found."); + }); + } - return new ListPromptsResult - { - Prompts = await promptService.ListAsync(), - }; - }) - .WithGetPromptHandler(async (request, cancellationToken) => - { - var promptService = request.Services.GetRequiredService(); + if (handlerBuilder.IncludePrompts) + { + builder + .WithListPromptsHandler(async (request, cancellationToken) => + { + var promptService = request.Services.GetRequiredService(); - return await promptService.GetAsync(request, cancellationToken); - }) - .WithListResourcesHandler(async (request, cancellationToken) => + return new ListPromptsResult { - var resourceService = request.Services.GetRequiredService(); - - return new ListResourcesResult - { - Resources = await resourceService.ListAsync(), - }; - }) - .WithListResourceTemplatesHandler(async (request, cancellationToken) => + Prompts = await promptService.ListAsync(), + }; + }) + .WithGetPromptHandler(async (request, cancellationToken) => + { + var promptService = request.Services.GetRequiredService(); + + return await promptService.GetAsync(request, cancellationToken); + }); + } + + if (handlerBuilder.IncludeResources) + { + builder + .WithListResourcesHandler(async (request, cancellationToken) => + { + var resourceService = request.Services.GetRequiredService(); + + return new ListResourcesResult { - var resourceService = request.Services.GetRequiredService(); - - return new ListResourceTemplatesResult - { - ResourceTemplates = await resourceService.ListTemplatesAsync(), - }; - }) - .WithReadResourceHandler(async (request, cancellationToken) => + Resources = await resourceService.ListAsync(), + }; + }) + .WithListResourceTemplatesHandler(async (request, cancellationToken) => + { + var resourceService = request.Services.GetRequiredService(); + + return new ListResourceTemplatesResult { - var resourceService = request.Services.GetRequiredService(); - - return await resourceService.ReadAsync(request, cancellationToken); - }); + ResourceTemplates = await resourceService.ListTemplatesAsync(), + }; + }) + .WithReadResourceHandler(async (request, cancellationToken) => + { + var resourceService = request.Services.GetRequiredService(); + + return await resourceService.ReadAsync(request, cancellationToken); + }); + } + + return builder; } } diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs index a6336e9a..cc8aa99a 100644 --- a/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Moq; @@ -284,19 +285,235 @@ public async Task ListToolsHandler_DoesNotObserveCancellation() Assert.Equal(["local", "sdk"], result.Tools.Select(tool => tool.Name)); } + /// + /// Verifies that excluding tools omits the tool handlers while keeping prompt and resource handlers. + /// + [Fact] + public void WithoutTools_OmitsToolHandlersButKeepsPromptAndResourceHandlers() + { + var services = CreateServices(handlers => handlers.WithoutTools()); + + using var serviceProvider = services.BuildServiceProvider(); + var handlers = serviceProvider.GetRequiredService>().Value.Handlers; + + Assert.Null(handlers.ListToolsHandler); + Assert.Null(handlers.CallToolHandler); + Assert.NotNull(handlers.ListPromptsHandler); + Assert.NotNull(handlers.GetPromptHandler); + Assert.NotNull(handlers.ListResourcesHandler); + Assert.NotNull(handlers.ListResourceTemplatesHandler); + Assert.NotNull(handlers.ReadResourceHandler); + } + + /// + /// Verifies that excluding prompts omits only the prompt handlers. + /// + [Fact] + public void WithoutPrompts_OmitsPromptHandlersOnly() + { + var services = CreateServices(handlers => handlers.WithoutPrompts()); + + using var serviceProvider = services.BuildServiceProvider(); + var handlers = serviceProvider.GetRequiredService>().Value.Handlers; + + Assert.Null(handlers.ListPromptsHandler); + Assert.Null(handlers.GetPromptHandler); + Assert.NotNull(handlers.ListToolsHandler); + Assert.NotNull(handlers.CallToolHandler); + Assert.NotNull(handlers.ListResourcesHandler); + } + + /// + /// Verifies that excluding resources omits only the resource handlers. + /// + [Fact] + public void WithoutResources_OmitsResourceHandlersOnly() + { + var services = CreateServices(handlers => handlers.WithoutResources()); + + using var serviceProvider = services.BuildServiceProvider(); + var handlers = serviceProvider.GetRequiredService>().Value.Handlers; + + Assert.Null(handlers.ListResourcesHandler); + Assert.Null(handlers.ListResourceTemplatesHandler); + Assert.Null(handlers.ReadResourceHandler); + Assert.NotNull(handlers.ListToolsHandler); + Assert.NotNull(handlers.ListPromptsHandler); + } + + /// + /// Verifies that a category filter exposes only tools assigned to a matching category. + /// + [Fact] + public async Task ListToolsHandler_WithToolsInCategory_ExposesOnlyMatchingCategory() + { + var services = CreateServices(handlers => handlers.WithToolsInCategory("knowledgebase")); + + AddLocalTool(services, "search-key", new TestAIFunction("search"), entry => entry.Category = "knowledgebase"); + AddLocalTool(services, "create-key", new TestAIFunction("create"), entry => entry.Category = "content"); + + using var serviceProvider = services.BuildServiceProvider(); + + var result = await InvokeListToolsHandlerAsync( + serviceProvider, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(["search"], result.Tools.Select(tool => tool.Name)); + } + + /// + /// Verifies that a purpose filter exposes only tools tagged with a matching purpose. + /// + [Fact] + public async Task ListToolsHandler_WithToolsForPurpose_ExposesOnlyMatchingPurpose() + { + var services = CreateServices(handlers => handlers.WithToolsForPurpose(AIToolPurposes.DataSourceSearch)); + + AddLocalTool(services, "search-key", new TestAIFunction("search"), entry => entry.Purpose = AIToolPurposes.DataSourceSearch); + AddLocalTool(services, "image-key", new TestAIFunction("image"), entry => entry.Purpose = AIToolPurposes.ContentGeneration); + + using var serviceProvider = services.BuildServiceProvider(); + + var result = await InvokeListToolsHandlerAsync( + serviceProvider, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(["search"], result.Tools.Select(tool => tool.Name)); + } + + /// + /// Verifies that a name filter exposes only the explicitly named tools. + /// + [Fact] + public async Task ListToolsHandler_WithToolNames_ExposesOnlyNamedTools() + { + var services = CreateServices(handlers => handlers.WithToolNames("search-key")); + + AddLocalTool(services, "search-key", new TestAIFunction("search")); + AddLocalTool(services, "create-key", new TestAIFunction("create")); + + using var serviceProvider = services.BuildServiceProvider(); + + var result = await InvokeListToolsHandlerAsync( + serviceProvider, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(["search"], result.Tools.Select(tool => tool.Name)); + } + + /// + /// Verifies that combined filters are AND-composed so a tool must satisfy every filter. + /// + [Fact] + public async Task ListToolsHandler_CombinedFilters_RequireEveryFilterToMatch() + { + var services = CreateServices(handlers => handlers + .WithToolsInCategory("knowledgebase") + .WithToolsForPurpose(AIToolPurposes.DataSourceSearch)); + + AddLocalTool(services, "match-key", new TestAIFunction("match"), entry => + { + entry.Category = "knowledgebase"; + entry.Purpose = AIToolPurposes.DataSourceSearch; + }); + AddLocalTool(services, "category-only-key", new TestAIFunction("category-only"), entry => entry.Category = "knowledgebase"); + + using var serviceProvider = services.BuildServiceProvider(); + + var result = await InvokeListToolsHandlerAsync( + serviceProvider, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(["match"], result.Tools.Select(tool => tool.Name)); + } + + /// + /// Verifies that excluding SDK tools omits them from the list while keeping local tools. + /// + [Fact] + public async Task ListToolsHandler_WithoutSdkTools_OmitsSdkTools() + { + var services = CreateServices( + handlers => handlers.WithoutSdkTools(), + CreateSdkTool("sdk")); + + AddLocalTool(services, "local-key", new TestAIFunction("local")); + + using var serviceProvider = services.BuildServiceProvider(); + + var result = await InvokeListToolsHandlerAsync( + serviceProvider, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(["local"], result.Tools.Select(tool => tool.Name)); + } + + /// + /// Verifies that a tool filtered out of the list cannot be invoked through the call handler. + /// + [Fact] + public async Task CallToolHandler_RejectsFilteredOutTool() + { + var services = CreateServices(handlers => handlers.WithToolNames("search")); + + AddLocalTool(services, "search", new TestAIFunction("search")); + AddLocalTool(services, "create", new TestAIFunction("create")); + + using var serviceProvider = services.BuildServiceProvider(); + + await Assert.ThrowsAsync(async () => + await InvokeCallToolHandlerAsync( + serviceProvider, + "create", + TestContext.Current.CancellationToken)); + } + + /// + /// Verifies that a tool that passes the filter can be invoked through the call handler. + /// + [Fact] + public async Task CallToolHandler_InvokesAllowedTool() + { + var services = CreateServices(handlers => handlers.WithToolNames("search")); + + AddLocalTool(services, "search", new TestAIFunction("search")); + + using var serviceProvider = services.BuildServiceProvider(); + + var result = await InvokeCallToolHandlerAsync( + serviceProvider, + "search", + TestContext.Current.CancellationToken); + + Assert.NotNull(result); + } + /// /// Creates the MCP service collection and registers the CrestApps handlers. /// /// The SDK tools to register in enumeration order. /// The configured service collection. private static ServiceCollection CreateServices(params McpServerTool[] sdkTools) + { + return CreateServices(configure: null, sdkTools); + } + + /// + /// Creates the MCP service collection and registers the CrestApps handlers with a configuration delegate. + /// + /// The handler configuration delegate. + /// The SDK tools to register in enumeration order. + /// The configured service collection. + private static ServiceCollection CreateServices( + Action configure, + params McpServerTool[] sdkTools) { var services = new ServiceCollection(); var builder = services.AddMcpServer(); services.AddOptions(); builder.WithTools(sdkTools); - builder.WithCrestAppsHandlers(); + builder.WithCrestAppsHandlers(configure); return services; } @@ -318,6 +535,33 @@ private static void AddLocalTool( services.AddKeyedSingleton(registrationName, tool); } + /// + /// Registers a local tool definition with custom metadata and a keyed tool instance. + /// + /// The service collection. + /// The keyed registration name. + /// The local AI function. + /// A delegate that configures the tool definition entry. + private static void AddLocalTool( + IServiceCollection services, + string registrationName, + AIFunction tool, + Action configureEntry) + { + services.Configure(options => + { + var entry = new AIToolDefinitionEntry(typeof(TestAIFunction)) + { + Name = registrationName, + }; + + configureEntry?.Invoke(entry); + options.SetTool(registrationName, entry); + }); + + services.AddKeyedSingleton(registrationName, tool); + } + /// /// Registers a local tool definition without registering its keyed implementation. /// @@ -377,7 +621,41 @@ private static async ValueTask InvokeListToolsHandlerAsync( } /// - /// Creates an SDK MCP tool with the supplied protocol metadata. + /// Invokes the registered CrestApps call-tool handler. + /// + /// The provider containing the registered handler. + /// The name of the tool to invoke. + /// The cancellation token passed to the handler. + /// The call-tool result. + private static async ValueTask InvokeCallToolHandlerAsync( + IServiceProvider serviceProvider, + string toolName, + CancellationToken cancellationToken = default) + { + var options = serviceProvider.GetRequiredService>().Value; + var handler = options.Handlers.CallToolHandler; + var server = new Mock(); + + Assert.NotNull(handler); + server.SetupGet(instance => instance.Services).Returns(serviceProvider); + + var request = new RequestContext( + server.Object, + new JsonRpcRequest + { + Method = RequestMethods.ToolsCall, + Id = new RequestId("1"), + }, + new CallToolRequestParams + { + Name = toolName, + }) + { + Services = serviceProvider, + }; + + return await handler(request, cancellationToken); + } /// /// The tool name. /// The optional description. From 92afe888ddc1db3f670b1831d8e48717aec7f1fa Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 9 Aug 2026 19:13:49 +0300 Subject: [PATCH 02/13] Add opt-in documentation search tool to MCP Adds a registerable `search_documentation` AI tool (in CrestApps.Core.AI.Mcp) that searches one or more configured documentation sites (Docusaurus, MkDocs, etc.) as a knowledge base and returns relevant passages with source URLs. - IDocumentationSource abstraction with request/result models and an IDocumentationSourceProvider that aggregates code-registered custom sources with sites materialized from DocumentationSearchOptions. - Built-in SitemapDocumentationSource crawls a site's sitemap.xml, strips HTML to text, caches the corpus, and ranks with keyword scoring. - Opt-in registration via AddCoreAIDocumentationSearch(...) and the AddDocumentationSearch(...) MCP server builder method; tagged under the "knowledgebase" category so a read-only MCP server can expose it with WithToolsInCategory("knowledgebase"). - Sites configurable in code (AddSite) or bound from configuration. - Tests and docs (new mcp/documentation-search page, sidebar, changelog). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/changelog/1.1.0.md | 11 + .../docs/mcp/documentation-search.md | 149 +++++++ src/CrestApps.Core.Docs/sidebars.js | 1 + .../DefaultDocumentationSourceProvider.cs | 69 ++++ .../DocumentationSearchBuilder.cs | 82 ++++ .../DocumentationSearchOptions.cs | 34 ++ .../DocumentationSearchRequest.cs | 26 ++ .../DocumentationSearchResult.cs | 33 ++ .../Documentation/DocumentationSite.cs | 43 ++ .../Documentation/DocumentationSiteKind.cs | 24 ++ .../Documentation/IDocumentationSource.cs | 23 ++ .../IDocumentationSourceProvider.cs | 15 + .../SitemapDocumentationSource.cs | 376 ++++++++++++++++++ .../Functions/DocumentationSearchFunction.cs | 178 +++++++++ .../CrestApps.Core.AI.Mcp/McpConstants.cs | 5 + .../ServiceCollectionExtensions.cs | 55 +++ .../Core/Mcp/DocumentationSearchTests.cs | 229 +++++++++++ 17 files changed, 1353 insertions(+) create mode 100644 src/CrestApps.Core.Docs/docs/mcp/documentation-search.md create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchBuilder.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchRequest.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchResult.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKind.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSource.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceProvider.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSource.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Functions/DocumentationSearchFunction.cs create mode 100644 tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index 58c3056c..1219a655 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -32,6 +32,17 @@ page will be updated as changes land after 1.0.0. change is additive and backward compatible: the existing parameterless `WithCrestAppsHandlers()` still registers every capability and exposes all non-hidden tools +## Documentation search tool + +- adds an opt-in `search_documentation` AI tool (in `CrestApps.Core.AI.Mcp`) that searches one or more + configured documentation sites (such as Docusaurus or MkDocs) as a knowledge base and returns the most + relevant passages with their source URLs. Register it with `AddCoreAIDocumentationSearch(...)` or the + `AddDocumentationSearch(...)` MCP server builder method; it is not registered by any default AI + registration. Sites are declared in code with `AddSite(...)` or bound from configuration through + `DocumentationSearchOptions`, and custom sources can be plugged in by implementing `IDocumentationSource`. + The tool is registered under the `knowledgebase` category so a read-only knowledge-base MCP server can + expose it selectively with `WithToolsInCategory("knowledgebase")` + ## Fixes - fixes post-session processing endlessly retrying and eventually failing when the AI returned a successful (HTTP 200) response that could not be parsed into structured task results. The no-tools structured output path now records a `Failed` result with a diagnostic message instead of silently returning no result, so these responses no longer exhaust all retry attempts. The unparseable-response case is now logged at `Warning` (including a preview of the raw AI response) instead of only at `Debug`, and the recorded task error message now explains that the AI produced no parseable result or there was no content to evaluate. diff --git a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md new file mode 100644 index 00000000..22c5fefb --- /dev/null +++ b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md @@ -0,0 +1,149 @@ +--- +sidebar_label: Documentation Search +sidebar_position: 5 +title: Documentation Search +description: Expose an opt-in AI tool that searches one or more public documentation sites as a knowledge base. +--- + +# Documentation Search + +> Register an opt-in AI tool that searches one or more configured documentation sites (such as Docusaurus or MkDocs) and returns the most relevant passages with their source URLs. + +## Problem & Solution + +A knowledge-base MCP server often needs to answer questions from product or framework documentation +that lives on public sites. Instead of indexing that content into a vector store, the documentation +search tool lets you declare a set of documentation sites and scan them on demand. The tool is +**opt-in** — it is not registered by any default AI registration, so it only appears when you call +`AddCoreAIDocumentationSearch(...)` (or the `AddDocumentationSearch(...)` builder method). This makes +it a good fit for a read-only knowledge-base server that should search documentation but perform no +actions. + +## Quick Start + +```csharp +builder.Services.AddCrestAppsCore(crestApps => crestApps + .AddAISuite(ai => ai + .AddMcpServer(mcpServer => mcpServer + .AddYesSqlStores() + .AddDocumentationSearch(docs => docs + .AddSite("crestapps", "https://core.crestapps.com") + .AddSite("orchardcore", "https://docs.orchardcore.net") + ) + ) + ) +); +``` + +Or register it directly on the service collection: + +```csharp +builder.Services.AddCoreAIDocumentationSearch(docs => docs + .AddSite("crestapps", "https://core.crestapps.com")); +``` + +The tool is registered under the name `search_documentation` with the category `knowledgebase` and the +purpose `data_source_search`. Because it carries a category, a knowledge-base MCP server can expose it +selectively: + +```csharp +_ = builder.Services.AddMcpServer() + .WithHttpTransport() + .WithCrestAppsHandlers(handlers => handlers + .WithToolsInCategory(DocumentationSearchFunction.Category)); +``` + +See [MCP Server](./server.md#selecting-which-capabilities-to-expose) for capability and tool selection. + +## Configuring Sites + +Sites can be declared in code with `AddSite(...)`, or bound from configuration by configuring +`DocumentationSearchOptions`: + +```csharp +builder.Services.AddCoreAIDocumentationSearch(); +builder.Services.Configure( + builder.Configuration.GetSection("DocumentationSearch")); +``` + +```json +{ + "DocumentationSearch": { + "MaxResultsPerSite": 5, + "MaxPagesPerSite": 200, + "CacheDuration": "01:00:00", + "Sites": [ + { "Name": "crestapps", "BaseUrl": "https://core.crestapps.com", "Kind": "Docusaurus" }, + { "Name": "orchardcore", "BaseUrl": "https://docs.orchardcore.net", "Kind": "MkDocs" } + ] + } +} +``` + +The built-in crawler discovers pages through the site's `sitemap.xml`. Supply `SitemapUrl` on a site to +override the default `{BaseUrl}/sitemap.xml` location. Both Docusaurus and MkDocs publish a standard +sitemap, so the `Kind` value is a hint only. + +### DocumentationSearchOptions + +| Property | Default | Description | +|----------|---------|-------------| +| `Sites` | empty | The public documentation sites the crawler scans. | +| `MaxResultsPerSite` | `5` | Default maximum results a single site contributes to a search. | +| `MaxPagesPerSite` | `200` | Default maximum pages the crawler indexes per site. | +| `MaxConcurrentRequests` | `4` | Maximum concurrent page requests per site while crawling. | +| `CacheDuration` | `1 hour` | How long a crawled site corpus is cached before it is refreshed. | + +### DocumentationSite + +| Property | Description | +|----------|-------------| +| `Name` | Unique logical name; a caller can scope a search to this source. | +| `BaseUrl` | Base URL of the documentation site. | +| `SitemapUrl` | Optional explicit sitemap URL. | +| `Kind` | `Auto`, `Docusaurus`, or `MkDocs` (hint only). | +| `MaxResults` | Optional per-site override for the maximum results. | +| `MaxPages` | Optional per-site override for the maximum indexed pages. | + +The first search against a site crawls its pages and caches the corpus in memory for `CacheDuration`; +subsequent searches reuse the cache. Ranking uses lightweight keyword scoring. + +## Custom Sources + +Implement `IDocumentationSource` to search anything that is not a public sitemap-based site (for +example a local corpus, a search API, or a vector index) and register it with `AddSource`: + +```csharp +public sealed class MyDocsSource : IDocumentationSource +{ + public string Name => "my-docs"; + + public Task> SearchAsync( + DocumentationSearchRequest request, + CancellationToken cancellationToken) + { + // Return matches ordered by descending Score. + } +} +``` + +```csharp +.AddDocumentationSearch(docs => docs + .AddSource() + .AddSite("crestapps", "https://core.crestapps.com")) +``` + +Custom sources and configured sites are aggregated by `IDocumentationSourceProvider`. When the model +calls the tool without a `source` argument, every source is searched and results are merged by score; +passing a `source` argument scopes the search to that single named source. + +## How It Works + +1. `AddCoreAIDocumentationSearch(...)` registers the `search_documentation` tool, the + `DefaultDocumentationSourceProvider`, and a named `HttpClient` with standard resilience. +2. When invoked, the tool resolves `IDocumentationSourceProvider` to get all sources (custom sources + plus a `SitemapDocumentationSource` per configured site). +3. Each source is searched in parallel; a failing source is skipped so one broken site does not fail + the whole search. +4. Results are merged, ordered by descending relevance, and returned with their titles and URLs so the + model can cite them. diff --git a/src/CrestApps.Core.Docs/sidebars.js b/src/CrestApps.Core.Docs/sidebars.js index c9e45181..87235172 100644 --- a/src/CrestApps.Core.Docs/sidebars.js +++ b/src/CrestApps.Core.Docs/sidebars.js @@ -71,6 +71,7 @@ const sidebars = { 'mcp/client', 'mcp/resource-types', 'mcp/server', + 'mcp/documentation-search', ], }, { diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs new file mode 100644 index 00000000..98ea237c --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; +using System.Net.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Default that combines documentation sources registered +/// in code with built-in crawler sources materialized from . +/// Crawler sources are created once per configured site and reused so their in-memory corpus is cached. +/// +public sealed class DefaultDocumentationSourceProvider : IDocumentationSourceProvider +{ + private readonly IEnumerable _customSources; + private readonly IOptions _options; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILoggerFactory _loggerFactory; + private readonly TimeProvider _timeProvider; + private readonly ConcurrentDictionary _siteSources = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class. + /// + /// The documentation sources registered in code. + /// The documentation search options. + /// The HTTP client factory. + /// The logger factory. + /// The time provider. + public DefaultDocumentationSourceProvider( + IEnumerable customSources, + IOptions options, + IHttpClientFactory httpClientFactory, + ILoggerFactory loggerFactory, + TimeProvider timeProvider) + { + _customSources = customSources; + _options = options; + _httpClientFactory = httpClientFactory; + _loggerFactory = loggerFactory; + _timeProvider = timeProvider; + } + + /// + public IReadOnlyList GetSources() + { + var options = _options.Value; + var sources = new List(_customSources); + + foreach (var site in options.Sites) + { + if (string.IsNullOrWhiteSpace(site.Name) || string.IsNullOrWhiteSpace(site.BaseUrl)) + { + continue; + } + + var source = _siteSources.GetOrAdd(site.Name, _ => new SitemapDocumentationSource( + site, + options, + _httpClientFactory, + _timeProvider, + _loggerFactory.CreateLogger())); + + sources.Add(source); + } + + return sources; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchBuilder.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchBuilder.cs new file mode 100644 index 00000000..5fec43bd --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchBuilder.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// A fluent builder for configuring the documentation search sources that the documentation search +/// tool can scan. Register public documentation sites through +/// or plug in custom sources through the AddSource overloads. +/// +public sealed class DocumentationSearchBuilder +{ + /// + /// Initializes a new instance of the class. + /// + /// The service collection. + public DocumentationSearchBuilder(IServiceCollection services) + { + Services = services; + } + + /// + /// Gets the used to register documentation search services. + /// + public IServiceCollection Services { get; } + + /// + /// Registers a public documentation site that the built-in crawler scans through its + /// sitemap.xml. + /// + /// The unique logical name of the site. + /// The base URL of the documentation site. + /// An optional action used to further configure the site. + /// The same builder instance for chaining. + public DocumentationSearchBuilder AddSite(string name, string baseUrl, Action configure = null) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(baseUrl); + + Services.Configure(options => + { + var site = new DocumentationSite + { + Name = name, + BaseUrl = baseUrl, + }; + + configure?.Invoke(site); + + options.Sites.Add(site); + }); + + return this; + } + + /// + /// Registers a custom documentation source implementation. + /// + /// The documentation source type. + /// The same builder instance for chaining. + public DocumentationSearchBuilder AddSource() + where TSource : class, IDocumentationSource + { + Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + return this; + } + + /// + /// Registers a custom documentation source instance. + /// + /// The documentation source instance. + /// The same builder instance for chaining. + public DocumentationSearchBuilder AddSource(IDocumentationSource source) + { + ArgumentNullException.ThrowIfNull(source); + + Services.TryAddEnumerable(ServiceDescriptor.Singleton(source)); + + return this; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs new file mode 100644 index 00000000..88f04f37 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs @@ -0,0 +1,34 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Options that control the built-in documentation search sources. Configure sites in code through +/// the documentation search builder, or bind this type from configuration to declare the public +/// documentation sites the search tool is allowed to scan. +/// +public sealed class DocumentationSearchOptions +{ + /// + /// Gets the collection of public documentation sites that the built-in crawler scans. + /// + public IList Sites { get; } = []; + + /// + /// Gets or sets the default maximum number of results a single site contributes to a search. + /// + public int MaxResultsPerSite { get; set; } = 5; + + /// + /// Gets or sets the default maximum number of pages the crawler indexes per site. + /// + public int MaxPagesPerSite { get; set; } = 200; + + /// + /// Gets or sets the maximum number of concurrent page requests the crawler issues per site. + /// + public int MaxConcurrentRequests { get; set; } = 4; + + /// + /// Gets or sets how long a crawled site corpus is cached before it is refreshed. + /// + public TimeSpan CacheDuration { get; set; } = TimeSpan.FromHours(1); +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchRequest.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchRequest.cs new file mode 100644 index 00000000..9311eb32 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchRequest.cs @@ -0,0 +1,26 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Represents a single documentation search query issued against an . +/// +public sealed class DocumentationSearchRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// The free-text search query. + public DocumentationSearchRequest(string query) + { + Query = query; + } + + /// + /// Gets the free-text search query. + /// + public string Query { get; } + + /// + /// Gets or sets the maximum number of results the source should return. + /// + public int MaxResults { get; set; } = 5; +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchResult.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchResult.cs new file mode 100644 index 00000000..a43c6969 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchResult.cs @@ -0,0 +1,33 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Represents a single relevant document returned by an . +/// +public sealed class DocumentationSearchResult +{ + /// + /// Gets or sets the logical name of the source that produced this result. + /// + public string SourceName { get; set; } + + /// + /// Gets or sets the title of the matching document. + /// + public string Title { get; set; } + + /// + /// Gets or sets the canonical URL of the matching document. + /// + public string Url { get; set; } + + /// + /// Gets or sets a short text excerpt that highlights the match. + /// + public string Snippet { get; set; } + + /// + /// Gets or sets the relevance score. Higher values indicate a stronger match. Scores are only + /// comparable within a single source. + /// + public double Score { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs new file mode 100644 index 00000000..53b5aff5 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs @@ -0,0 +1,43 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Describes a public documentation site that the built-in documentation search crawler can scan. +/// A site is identified by its name and base URL; the crawler discovers pages through the site's +/// sitemap.xml unless an explicit is supplied. +/// +public sealed class DocumentationSite +{ + /// + /// Gets or sets the unique logical name of the site. The documentation search tool uses this + /// value to let a caller scope a search to a single source. + /// + public string Name { get; set; } + + /// + /// Gets or sets the base URL of the documentation site (for example https://docs.example.com). + /// + public string BaseUrl { get; set; } + + /// + /// Gets or sets an explicit sitemap URL. When not set, the crawler resolves the sitemap from + /// by appending /sitemap.xml. + /// + public string SitemapUrl { get; set; } + + /// + /// Gets or sets the documentation generator hint for this site. + /// + public DocumentationSiteKind Kind { get; set; } = DocumentationSiteKind.Auto; + + /// + /// Gets or sets the maximum number of results this site should contribute to a search. When not + /// set, the global value is used. + /// + public int? MaxResults { get; set; } + + /// + /// Gets or sets the maximum number of pages the crawler indexes for this site. When not set, the + /// global value is used. + /// + public int? MaxPages { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKind.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKind.cs new file mode 100644 index 00000000..2ad2dc6c --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKind.cs @@ -0,0 +1,24 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Identifies the documentation generator that produced a configured site. The value is a hint that +/// lets the built-in crawler adapt to well-known layouts; most static generators expose a standard +/// sitemap.xml that the crawler can consume regardless of the selected kind. +/// +public enum DocumentationSiteKind +{ + /// + /// The generator is unknown and the crawler should use its generic sitemap-based strategy. + /// + Auto, + + /// + /// A Docusaurus documentation site. + /// + Docusaurus, + + /// + /// A MkDocs documentation site. + /// + MkDocs, +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSource.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSource.cs new file mode 100644 index 00000000..0fc1051d --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSource.cs @@ -0,0 +1,23 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Represents a searchable documentation knowledge base. Implement this interface to expose a custom +/// documentation source (for example a search index, an API, or a local corpus) to the documentation +/// search tool. Built-in sources are provided for public sites that expose a sitemap.xml. +/// +public interface IDocumentationSource +{ + /// + /// Gets the unique logical name of this source. Callers can use this value to scope a search to a + /// single source. + /// + string Name { get; } + + /// + /// Searches the source for documents relevant to the supplied request. + /// + /// The search request. + /// The cancellation token. + /// The relevant documents ordered by descending relevance. + Task> SearchAsync(DocumentationSearchRequest request, CancellationToken cancellationToken); +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceProvider.cs new file mode 100644 index 00000000..04489f8f --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceProvider.cs @@ -0,0 +1,15 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Resolves the complete set of documentation sources available to the documentation search tool. +/// The default implementation aggregates sources registered in code with the built-in crawler +/// sources materialized from . +/// +public interface IDocumentationSourceProvider +{ + /// + /// Gets all documentation sources that can be searched. + /// + /// The available documentation sources. + IReadOnlyList GetSources(); +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSource.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSource.cs new file mode 100644 index 00000000..6b2fc9da --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSource.cs @@ -0,0 +1,376 @@ +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// A built-in that indexes a public documentation site by reading +/// its sitemap.xml, fetching each page, and ranking pages against a query using lightweight +/// keyword scoring. The crawled corpus is cached in memory and refreshed based on +/// . +/// +public sealed partial class SitemapDocumentationSource : IDocumentationSource +{ + private readonly DocumentationSite _site; + private readonly DocumentationSearchOptions _options; + private readonly IHttpClientFactory _httpClientFactory; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly SemaphoreSlim _loadLock = new(1, 1); + + private IReadOnlyList _pages = []; + private DateTimeOffset _loadedAt = DateTimeOffset.MinValue; + + /// + /// Initializes a new instance of the class. + /// + /// The site configuration. + /// The global documentation search options. + /// The HTTP client factory. + /// The time provider. + /// The logger. + public SitemapDocumentationSource( + DocumentationSite site, + DocumentationSearchOptions options, + IHttpClientFactory httpClientFactory, + TimeProvider timeProvider, + ILogger logger) + { + _site = site; + _options = options; + _httpClientFactory = httpClientFactory; + _timeProvider = timeProvider; + _logger = logger; + } + + /// + public string Name => _site.Name; + + /// + public async Task> SearchAsync(DocumentationSearchRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(request.Query)) + { + return []; + } + + var pages = await GetPagesAsync(cancellationToken); + + if (pages.Count == 0) + { + return []; + } + + var terms = Tokenize(request.Query); + + if (terms.Length == 0) + { + return []; + } + + var maxResults = _site.MaxResults ?? _options.MaxResultsPerSite; + + var scored = new List(); + + foreach (var page in pages) + { + var score = Score(page, terms); + + if (score <= 0) + { + continue; + } + + scored.Add(new DocumentationSearchResult + { + SourceName = _site.Name, + Title = string.IsNullOrWhiteSpace(page.Title) ? page.Url : page.Title, + Url = page.Url, + Snippet = BuildSnippet(page.Text, terms), + Score = score, + }); + } + + return scored + .OrderByDescending(result => result.Score) + .Take(Math.Min(request.MaxResults, maxResults)) + .ToList(); + } + + private async Task> GetPagesAsync(CancellationToken cancellationToken) + { + var now = _timeProvider.GetUtcNow(); + + if (_pages.Count > 0 && now - _loadedAt < _options.CacheDuration) + { + return _pages; + } + + await _loadLock.WaitAsync(cancellationToken); + + try + { + now = _timeProvider.GetUtcNow(); + + if (_pages.Count > 0 && now - _loadedAt < _options.CacheDuration) + { + return _pages; + } + + _pages = await CrawlAsync(cancellationToken); + _loadedAt = _timeProvider.GetUtcNow(); + + return _pages; + } + finally + { + _loadLock.Release(); + } + } + + private async Task> CrawlAsync(CancellationToken cancellationToken) + { + var client = _httpClientFactory.CreateClient(McpConstants.DocumentationHttpClientName); + var urls = await GetSitemapUrlsAsync(client, cancellationToken); + + if (urls.Count == 0) + { + return []; + } + + var maxPages = _site.MaxPages ?? _options.MaxPagesPerSite; + + if (urls.Count > maxPages) + { + urls = urls.Take(maxPages).ToList(); + } + + var pages = new List(urls.Count); + using var throttle = new SemaphoreSlim(Math.Max(1, _options.MaxConcurrentRequests)); + + var tasks = urls.Select(async url => + { + await throttle.WaitAsync(cancellationToken); + + try + { + return await FetchPageAsync(client, url, cancellationToken); + } + finally + { + throttle.Release(); + } + }); + + foreach (var page in await Task.WhenAll(tasks)) + { + if (page is not null) + { + pages.Add(page); + } + } + + return pages; + } + + private async Task> GetSitemapUrlsAsync(HttpClient client, CancellationToken cancellationToken) + { + var sitemapUrl = ResolveSitemapUrl(); + + try + { + var xml = await client.GetStringAsync(sitemapUrl, cancellationToken); + var document = XDocument.Parse(xml); + + var urls = document.Descendants() + .Where(element => string.Equals(element.Name.LocalName, "loc", StringComparison.OrdinalIgnoreCase)) + .Select(element => element.Value?.Trim()) + .Where(value => !string.IsNullOrEmpty(value) && !value.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return urls; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read sitemap '{SitemapUrl}' for documentation source '{SourceName}'.", sitemapUrl, _site.Name); + + return []; + } + } + + private async Task FetchPageAsync(HttpClient client, string url, CancellationToken cancellationToken) + { + try + { + var html = await client.GetStringAsync(url, cancellationToken); + var title = ExtractTitle(html); + var text = ExtractText(html); + + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + return new DocumentationPage(url, title, text); + } + catch (Exception ex) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug(ex, "Failed to fetch documentation page '{Url}' for source '{SourceName}'.", url, _site.Name); + } + + return null; + } + } + + private string ResolveSitemapUrl() + { + if (!string.IsNullOrWhiteSpace(_site.SitemapUrl)) + { + return _site.SitemapUrl; + } + + return $"{_site.BaseUrl.TrimEnd('/')}/sitemap.xml"; + } + + private static double Score(DocumentationPage page, string[] terms) + { + double score = 0; + var matchedTerms = 0; + + foreach (var term in terms) + { + var bodyCount = CountOccurrences(page.Text, term); + var titleCount = CountOccurrences(page.Title, term); + + if (bodyCount == 0 && titleCount == 0) + { + continue; + } + + matchedTerms++; + score += bodyCount + (titleCount * 5); + } + + if (matchedTerms == 0) + { + return 0; + } + + return score * matchedTerms; + } + + private static int CountOccurrences(string text, string term) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + var count = 0; + var index = 0; + + while ((index = text.IndexOf(term, index, StringComparison.OrdinalIgnoreCase)) >= 0) + { + count++; + index += term.Length; + } + + return count; + } + + private static string BuildSnippet(string text, string[] terms) + { + const int windowSize = 240; + + var matchIndex = -1; + + foreach (var term in terms) + { + var index = text.IndexOf(term, StringComparison.OrdinalIgnoreCase); + + if (index >= 0 && (matchIndex < 0 || index < matchIndex)) + { + matchIndex = index; + } + } + + if (matchIndex < 0) + { + return text.Length <= windowSize ? text : text[..windowSize] + "…"; + } + + var start = Math.Max(0, matchIndex - (windowSize / 2)); + var length = Math.Min(windowSize, text.Length - start); + var snippet = text.Substring(start, length).Trim(); + + if (start > 0) + { + snippet = "…" + snippet; + } + + if (start + length < text.Length) + { + snippet += "…"; + } + + return snippet; + } + + private static string[] Tokenize(string query) + { + return NonWordRegex() + .Split(query.ToLowerInvariant()) + .Where(token => token.Length >= 2) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + private static string ExtractTitle(string html) + { + var match = TitleRegex().Match(html); + + if (!match.Success) + { + return null; + } + + return System.Net.WebUtility.HtmlDecode(match.Groups[1].Value).Trim(); + } + + private static string ExtractText(string html) + { + var withoutScripts = ScriptRegex().Replace(html, " "); + var withoutStyles = StyleRegex().Replace(withoutScripts, " "); + var withoutTags = TagRegex().Replace(withoutStyles, " "); + var decoded = System.Net.WebUtility.HtmlDecode(withoutTags); + + return WhitespaceRegex().Replace(decoded, " ").Trim(); + } + + [GeneratedRegex(@"]*>.*?", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex ScriptRegex(); + + [GeneratedRegex(@"]*>.*?", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex StyleRegex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex TitleRegex(); + + [GeneratedRegex("<[^>]+>")] + private static partial Regex TagRegex(); + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); + + [GeneratedRegex(@"[^a-z0-9]+")] + private static partial Regex NonWordRegex(); + + private sealed record DocumentationPage(string Url, string Title, string Text); +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Functions/DocumentationSearchFunction.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/DocumentationSearchFunction.cs new file mode 100644 index 00000000..abcdf5cd --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/DocumentationSearchFunction.cs @@ -0,0 +1,178 @@ +using System.Text.Json; +using CrestApps.Core.AI.Extensions; +using CrestApps.Core.AI.Mcp.Documentation; +using Cysharp.Text; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Mcp.Functions; + +/// +/// An AI tool that searches one or more configured documentation knowledge bases (for example public +/// Docusaurus or MkDocs sites) and returns the most relevant passages with source URLs. The tool is +/// opt-in; it is only registered when a host calls the documentation search registration extension. +/// +public sealed class DocumentationSearchFunction : AIFunction +{ + /// + /// The registered technical name of this tool. + /// + public const string TheName = "search_documentation"; + + /// + /// The tool category used to group documentation search with other knowledge-base tools. + /// + public const string Category = "knowledgebase"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query used to find relevant documentation." + }, + "source": { + "type": "string", + "description": "Optional. The name of a single configured documentation source to search. When omitted, all sources are searched." + } + }, + "required": ["query"], + "additionalProperties": false + } + """); + + /// + /// Gets the name. + /// + public override string Name => TheName; + + /// + /// Gets the description. + /// + public override string Description => "Searches the configured documentation knowledge bases (such as Docusaurus or MkDocs sites) and returns the most relevant passages with their source URLs. Use this tool to answer questions from product or framework documentation."; + + /// + /// Gets the json Schema. + /// + public override JsonElement JsonSchema => _jsonSchema; + + /// + /// Gets the additional Properties. + /// + public override IReadOnlyDictionary AdditionalProperties { get; } = new Dictionary + { + ["Strict"] = false, + }; + + /// + /// Invokes the documentation search across the configured sources. + /// + /// The arguments. + /// The cancellation token. + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + var logger = arguments.Services.GetRequiredService>(); + + if (!arguments.TryGetFirstString("query", out var query) || string.IsNullOrWhiteSpace(query)) + { + logger.LogWarning("AI tool '{ToolName}' missing required argument 'query'.", Name); + + return "Unable to find a 'query' argument in the arguments parameter."; + } + + var provider = arguments.Services.GetRequiredService(); + var sources = provider.GetSources(); + + arguments.TryGetFirstString("source", out var sourceName); + + if (!string.IsNullOrWhiteSpace(sourceName)) + { + sources = sources + .Where(source => string.Equals(source.Name, sourceName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (sources.Count == 0) + { + return $"No documentation source named '{sourceName}' is configured."; + } + } + + if (sources.Count == 0) + { + return "No documentation sources are configured."; + } + + var request = new DocumentationSearchRequest(query); + + var searches = sources.Select(source => SearchSourceAsync(source, request, logger, cancellationToken)); + var resultsPerSource = await Task.WhenAll(searches); + + var results = resultsPerSource + .SelectMany(result => result) + .OrderByDescending(result => result.Score) + .ToList(); + + if (results.Count == 0) + { + return $"No documentation results were found for '{query}'."; + } + + using var builder = ZString.CreateStringBuilder(); + builder.Append("Documentation results for '"); + builder.Append(query); + builder.AppendLine("':"); + + var index = 0; + + foreach (var result in results) + { + index++; + builder.AppendLine(); + builder.Append('['); + builder.Append(index); + builder.Append("] "); + builder.Append(result.Title); + builder.Append(" — "); + builder.Append(result.Url); + + if (!string.IsNullOrWhiteSpace(result.SourceName)) + { + builder.Append(" (source: "); + builder.Append(result.SourceName); + builder.Append(')'); + } + + builder.AppendLine(); + + if (!string.IsNullOrWhiteSpace(result.Snippet)) + { + builder.AppendLine(result.Snippet); + } + } + + return builder.ToString(); + } + + private static async Task> SearchSourceAsync( + IDocumentationSource source, + DocumentationSearchRequest request, + ILogger logger, + CancellationToken cancellationToken) + { + try + { + var results = await source.SearchAsync(request, cancellationToken); + + return results ?? []; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Documentation source '{SourceName}' failed to search.", source.Name); + + return []; + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/McpConstants.cs b/src/Primitives/CrestApps.Core.AI.Mcp/McpConstants.cs index e187b9b4..178d15ea 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/McpConstants.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/McpConstants.cs @@ -12,6 +12,11 @@ public static class McpConstants /// public const string HttpClientName = "CrestApps.Mcp"; + /// + /// The name of the named used by the documentation search crawler. + /// + public const string DocumentationHttpClientName = "CrestApps.Documentation"; + /// /// Provides functionality for transport Types. /// diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs index 2df5248e..a132be2d 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ +using System.Linq; using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Mcp.Documentation; using CrestApps.Core.AI.Mcp.Functions; using CrestApps.Core.AI.Mcp.Handlers; using CrestApps.Core.AI.Mcp.Models; @@ -221,4 +223,57 @@ public static IServiceCollection AddCoreAIMcpResourceType( return services; } + + /// + /// Registers the opt-in documentation search tool together with the built-in documentation source + /// provider. The tool is not registered by any default AI registration; call this method to expose + /// it, then configure the documentation sites and custom sources it may scan. + /// + /// The service collection. + /// An optional action used to configure documentation sources. + /// The service collection for chaining. + public static IServiceCollection AddCoreAIDocumentationSearch( + this IServiceCollection services, + Action configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions(); + services.TryAddSingleton(TimeProvider.System); + services.AddHttpClient(McpConstants.DocumentationHttpClientName) + .AddStandardResilienceHandler(); + services.TryAddSingleton(); + + if (!services.Any(descriptor => descriptor.ServiceType == typeof(DocumentationSearchFunction))) + { + services.AddCoreAITool(DocumentationSearchFunction.TheName) + .WithCategory(DocumentationSearchFunction.Category) + .WithPurpose(AIToolPurposes.DataSourceSearch) + .WithTitle("Search documentation") + .WithDescription("Searches configured documentation knowledge bases and returns relevant passages with source URLs."); + } + + configure?.Invoke(new DocumentationSearchBuilder(services)); + + return services; + } + + /// + /// Registers the opt-in documentation search tool on an MCP server builder. This is a convenience + /// wrapper over + /// so a knowledge-base MCP server can expose documentation search alongside its other capabilities. + /// + /// The MCP server builder. + /// An optional action used to configure documentation sources. + /// The MCP server builder for chaining. + public static CrestAppsMcpServerBuilder AddDocumentationSearch( + this CrestAppsMcpServerBuilder builder, + Action configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddCoreAIDocumentationSearch(configure); + + return builder; + } } diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs new file mode 100644 index 00000000..94306a4c --- /dev/null +++ b/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs @@ -0,0 +1,229 @@ +using CrestApps.Core.AI.Mcp; +using CrestApps.Core.AI.Mcp.Documentation; +using CrestApps.Core.AI.Mcp.Functions; +using CrestApps.Core.AI.Tooling; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.Tests.Core.Mcp; + +public sealed class DocumentationSearchTests +{ + /// + /// Verifies that the registration extension registers the documentation search tool with the + /// expected category and purpose so it can be exposed and filtered by a knowledge-base MCP server. + /// + [Fact] + public void AddCoreAIDocumentationSearch_RegistersToolWithCategoryAndPurpose() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCoreAIDocumentationSearch(); + + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + + Assert.True(options.Tools.TryGetValue(DocumentationSearchFunction.TheName, out var entry)); + Assert.Equal(DocumentationSearchFunction.Category, entry.Category); + Assert.Equal(AIToolPurposes.DataSourceSearch, entry.Purpose); + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + } + + /// + /// Verifies that records the site in the options. + /// + [Fact] + public void AddSite_PopulatesOptions() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCoreAIDocumentationSearch(docs => docs + .AddSite("crestapps", "https://core.crestapps.com", site => site.Kind = DocumentationSiteKind.Docusaurus)); + + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + var site = Assert.Single(options.Sites); + + Assert.Equal("crestapps", site.Name); + Assert.Equal("https://core.crestapps.com", site.BaseUrl); + Assert.Equal(DocumentationSiteKind.Docusaurus, site.Kind); + } + + /// + /// Verifies that the source provider aggregates code-registered custom sources with the built-in + /// crawler sources materialized from the configured sites. + /// + [Fact] + public void SourceProvider_AggregatesCustomAndConfiguredSites() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCoreAIDocumentationSearch(docs => docs + .AddSite("site-1", "https://docs.example.com") + .AddSource(new FakeDocumentationSource("custom-1"))); + + using var provider = services.BuildServiceProvider(); + + var sources = provider.GetRequiredService().GetSources(); + + Assert.Contains(sources, source => source.Name == "custom-1"); + Assert.Contains(sources, source => source.Name == "site-1"); + } + + /// + /// Verifies that the tool returns a helpful message when no documentation sources are configured. + /// + [Fact] + public async Task InvokeAsync_WhenNoSourcesConfigured_ReturnsMessage() + { + using var provider = BuildProvider(); + + var result = await InvokeAsync(provider, "anything"); + + Assert.Equal("No documentation sources are configured.", result); + } + + /// + /// Verifies that the tool aggregates results across sources and orders them by descending score. + /// + [Fact] + public async Task InvokeAsync_AggregatesResultsOrderedByScore() + { + var low = new FakeDocumentationSource("source-a", new DocumentationSearchResult + { + SourceName = "source-a", + Title = "Low", + Url = "https://a/low", + Snippet = "low snippet", + Score = 1, + }); + + var high = new FakeDocumentationSource("source-b", new DocumentationSearchResult + { + SourceName = "source-b", + Title = "High", + Url = "https://b/high", + Snippet = "high snippet", + Score = 9, + }); + + using var provider = BuildProvider(low, high); + + var result = await InvokeAsync(provider, "topic"); + + Assert.Contains("[1] High — https://b/high", result); + Assert.Contains("[2] Low — https://a/low", result); + Assert.True(result.IndexOf("High", StringComparison.Ordinal) < result.IndexOf("Low", StringComparison.Ordinal)); + } + + /// + /// Verifies that supplying an unknown source name returns a message instead of silently searching all. + /// + [Fact] + public async Task InvokeAsync_WithUnknownSource_ReturnsMessage() + { + using var provider = BuildProvider(new FakeDocumentationSource("known")); + + var result = await InvokeAsync(provider, "topic", "missing"); + + Assert.Equal("No documentation source named 'missing' is configured.", result); + } + + /// + /// Verifies that supplying a source name scopes the search to that single source. + /// + [Fact] + public async Task InvokeAsync_WithSourceName_ScopesToNamedSource() + { + var wanted = new FakeDocumentationSource("wanted", new DocumentationSearchResult + { + SourceName = "wanted", + Title = "Wanted", + Url = "https://wanted/doc", + Snippet = "wanted snippet", + Score = 5, + }); + + var other = new FakeDocumentationSource("other", new DocumentationSearchResult + { + SourceName = "other", + Title = "Other", + Url = "https://other/doc", + Snippet = "other snippet", + Score = 8, + }); + + using var provider = BuildProvider(wanted, other); + + var result = await InvokeAsync(provider, "topic", "wanted"); + + Assert.Contains("Wanted", result); + Assert.DoesNotContain("Other", result); + } + + private static ServiceProvider BuildProvider(params IDocumentationSource[] sources) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new StubSourceProvider(sources)); + + return services.BuildServiceProvider(); + } + + private static async Task InvokeAsync(IServiceProvider provider, string query, string source = null) + { + var function = new DocumentationSearchFunction(); + var arguments = new AIFunctionArguments + { + Services = provider, + }; + + arguments["query"] = query; + + if (source is not null) + { + arguments["source"] = source; + } + + var result = await function.InvokeAsync(arguments, TestContext.Current.CancellationToken); + + return result?.ToString(); + } + + private sealed class StubSourceProvider : IDocumentationSourceProvider + { + private readonly IReadOnlyList _sources; + + public StubSourceProvider(IReadOnlyList sources) + { + _sources = sources; + } + + public IReadOnlyList GetSources() + { + return _sources; + } + } + + private sealed class FakeDocumentationSource : IDocumentationSource + { + private readonly DocumentationSearchResult[] _results; + + public FakeDocumentationSource(string name, params DocumentationSearchResult[] results) + { + Name = name; + _results = results; + } + + public string Name { get; } + + public Task> SearchAsync(DocumentationSearchRequest request, CancellationToken cancellationToken) + { + return Task.FromResult>(_results); + } + } +} From c5d7d0655074b5286716e9fd181f9b46db8f2373 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 9 Aug 2026 19:24:56 +0300 Subject: [PATCH 03/13] Make documentation site Kind an extensible string Replaces the DocumentationSiteKind enum with a free-form string and a DocumentationSiteKinds constants class so hosts can define their own generator kinds beyond Docusaurus and MkDocs. The value remains a hint; the crawler consumes any site exposing a standard sitemap.xml. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/mcp/documentation-search.md | 6 ++--- .../Documentation/DocumentationSite.cs | 6 +++-- .../Documentation/DocumentationSiteKind.cs | 24 ------------------- .../Documentation/DocumentationSiteKinds.cs | 20 ++++++++++++++++ .../Core/Mcp/DocumentationSearchTests.cs | 4 ++-- 5 files changed, 29 insertions(+), 31 deletions(-) delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKind.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKinds.cs diff --git a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md index 22c5fefb..726e53fc 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md +++ b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md @@ -73,8 +73,8 @@ builder.Services.Configure( "MaxPagesPerSite": 200, "CacheDuration": "01:00:00", "Sites": [ - { "Name": "crestapps", "BaseUrl": "https://core.crestapps.com", "Kind": "Docusaurus" }, - { "Name": "orchardcore", "BaseUrl": "https://docs.orchardcore.net", "Kind": "MkDocs" } + { "Name": "crestapps", "BaseUrl": "https://core.crestapps.com", "Kind": "docusaurus" }, + { "Name": "orchardcore", "BaseUrl": "https://docs.orchardcore.net", "Kind": "mkdocs" } ] } } @@ -101,7 +101,7 @@ sitemap, so the `Kind` value is a hint only. | `Name` | Unique logical name; a caller can scope a search to this source. | | `BaseUrl` | Base URL of the documentation site. | | `SitemapUrl` | Optional explicit sitemap URL. | -| `Kind` | `Auto`, `Docusaurus`, or `MkDocs` (hint only). | +| `Kind` | Optional generator hint (for example `docusaurus` or `mkdocs`); any custom string is allowed. | | `MaxResults` | Optional per-site override for the maximum results. | | `MaxPages` | Optional per-site override for the maximum indexed pages. | diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs index 53b5aff5..30c966e3 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs @@ -25,9 +25,11 @@ public sealed class DocumentationSite public string SitemapUrl { get; set; } /// - /// Gets or sets the documentation generator hint for this site. + /// Gets or sets the documentation generator hint for this site. Use a value from + /// or a custom identifier. When not set, the crawler uses its + /// generic sitemap-based strategy. /// - public DocumentationSiteKind Kind { get; set; } = DocumentationSiteKind.Auto; + public string Kind { get; set; } /// /// Gets or sets the maximum number of results this site should contribute to a search. When not diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKind.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKind.cs deleted file mode 100644 index 2ad2dc6c..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKind.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// Identifies the documentation generator that produced a configured site. The value is a hint that -/// lets the built-in crawler adapt to well-known layouts; most static generators expose a standard -/// sitemap.xml that the crawler can consume regardless of the selected kind. -/// -public enum DocumentationSiteKind -{ - /// - /// The generator is unknown and the crawler should use its generic sitemap-based strategy. - /// - Auto, - - /// - /// A Docusaurus documentation site. - /// - Docusaurus, - - /// - /// A MkDocs documentation site. - /// - MkDocs, -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKinds.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKinds.cs new file mode 100644 index 00000000..3b3dd403 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKinds.cs @@ -0,0 +1,20 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Well-known documentation generator identifiers used for the +/// hint. The value is an open-ended string so hosts can define their own kinds for custom generators; +/// the built-in crawler treats it as a hint and consumes any site that exposes a standard +/// sitemap.xml regardless of the selected kind. +/// +public static class DocumentationSiteKinds +{ + /// + /// A Docusaurus documentation site. + /// + public const string Docusaurus = "docusaurus"; + + /// + /// A MkDocs documentation site. + /// + public const string MkDocs = "mkdocs"; +} diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs index 94306a4c..5e341839 100644 --- a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs @@ -41,7 +41,7 @@ public void AddSite_PopulatesOptions() var services = new ServiceCollection(); services.AddLogging(); services.AddCoreAIDocumentationSearch(docs => docs - .AddSite("crestapps", "https://core.crestapps.com", site => site.Kind = DocumentationSiteKind.Docusaurus)); + .AddSite("crestapps", "https://core.crestapps.com", site => site.Kind = DocumentationSiteKinds.Docusaurus)); using var provider = services.BuildServiceProvider(); @@ -50,7 +50,7 @@ public void AddSite_PopulatesOptions() Assert.Equal("crestapps", site.Name); Assert.Equal("https://core.crestapps.com", site.BaseUrl); - Assert.Equal(DocumentationSiteKind.Docusaurus, site.Kind); + Assert.Equal(DocumentationSiteKinds.Docusaurus, site.Kind); } /// From 75eeddfb5b8f0a53d5d92f06e8af800f7494f613 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 9 Aug 2026 19:30:06 +0300 Subject: [PATCH 04/13] Remove unused documentation site Kind field The Kind hint did not affect crawling or search, so it was dead configuration. Removed the property and the DocumentationSiteKinds constants. It can be reintroduced later if generator-specific behavior is added (for example a Docusaurus search-index fast path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/mcp/documentation-search.md | 7 +++---- .../Documentation/DocumentationSite.cs | 7 ------- .../Documentation/DocumentationSiteKinds.cs | 20 ------------------- .../Core/Mcp/DocumentationSearchTests.cs | 4 ++-- 4 files changed, 5 insertions(+), 33 deletions(-) delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKinds.cs diff --git a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md index 726e53fc..c79384a4 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md +++ b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md @@ -73,8 +73,8 @@ builder.Services.Configure( "MaxPagesPerSite": 200, "CacheDuration": "01:00:00", "Sites": [ - { "Name": "crestapps", "BaseUrl": "https://core.crestapps.com", "Kind": "docusaurus" }, - { "Name": "orchardcore", "BaseUrl": "https://docs.orchardcore.net", "Kind": "mkdocs" } + { "Name": "crestapps", "BaseUrl": "https://core.crestapps.com" }, + { "Name": "orchardcore", "BaseUrl": "https://docs.orchardcore.net" } ] } } @@ -82,7 +82,7 @@ builder.Services.Configure( The built-in crawler discovers pages through the site's `sitemap.xml`. Supply `SitemapUrl` on a site to override the default `{BaseUrl}/sitemap.xml` location. Both Docusaurus and MkDocs publish a standard -sitemap, so the `Kind` value is a hint only. +sitemap, so no generator-specific configuration is required. ### DocumentationSearchOptions @@ -101,7 +101,6 @@ sitemap, so the `Kind` value is a hint only. | `Name` | Unique logical name; a caller can scope a search to this source. | | `BaseUrl` | Base URL of the documentation site. | | `SitemapUrl` | Optional explicit sitemap URL. | -| `Kind` | Optional generator hint (for example `docusaurus` or `mkdocs`); any custom string is allowed. | | `MaxResults` | Optional per-site override for the maximum results. | | `MaxPages` | Optional per-site override for the maximum indexed pages. | diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs index 30c966e3..859cb499 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs @@ -24,13 +24,6 @@ public sealed class DocumentationSite /// public string SitemapUrl { get; set; } - /// - /// Gets or sets the documentation generator hint for this site. Use a value from - /// or a custom identifier. When not set, the crawler uses its - /// generic sitemap-based strategy. - /// - public string Kind { get; set; } - /// /// Gets or sets the maximum number of results this site should contribute to a search. When not /// set, the global value is used. diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKinds.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKinds.cs deleted file mode 100644 index 3b3dd403..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSiteKinds.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// Well-known documentation generator identifiers used for the -/// hint. The value is an open-ended string so hosts can define their own kinds for custom generators; -/// the built-in crawler treats it as a hint and consumes any site that exposes a standard -/// sitemap.xml regardless of the selected kind. -/// -public static class DocumentationSiteKinds -{ - /// - /// A Docusaurus documentation site. - /// - public const string Docusaurus = "docusaurus"; - - /// - /// A MkDocs documentation site. - /// - public const string MkDocs = "mkdocs"; -} diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs index 5e341839..9dee17fe 100644 --- a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs @@ -41,7 +41,7 @@ public void AddSite_PopulatesOptions() var services = new ServiceCollection(); services.AddLogging(); services.AddCoreAIDocumentationSearch(docs => docs - .AddSite("crestapps", "https://core.crestapps.com", site => site.Kind = DocumentationSiteKinds.Docusaurus)); + .AddSite("crestapps", "https://core.crestapps.com", site => site.MaxResults = 3)); using var provider = services.BuildServiceProvider(); @@ -50,7 +50,7 @@ public void AddSite_PopulatesOptions() Assert.Equal("crestapps", site.Name); Assert.Equal("https://core.crestapps.com", site.BaseUrl); - Assert.Equal(DocumentationSiteKinds.Docusaurus, site.Kind); + Assert.Equal(3, site.MaxResults); } /// From ae8749fb7544cd1534a22b90b04522d80ac1052c Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 9 Aug 2026 19:47:19 +0300 Subject: [PATCH 05/13] Add search-index and Algolia DocSearch documentation strategies Adds two additional opt-in documentation search strategies alongside the sitemap crawler, each with its own builder method and configuration model: - AddSearchIndex(...) downloads a prebuilt JSON search index (for example a MkDocs Material search_index.json) and ranks it locally. - AddAlgoliaDocSearch(...) forwards queries to the hosted Algolia DocSearch API used by many Docusaurus sites. Introduces a shared DocumentationCorpus for consistent keyword ranking and a CachingDocumentationSource base for the local (non-Algolia) sources. Both new strategies bind from configuration through the new SearchIndexes and AlgoliaSources lists on DocumentationSearchOptions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/changelog/1.1.0.md | 6 + .../docs/mcp/documentation-search.md | 77 ++++++- .../Documentation/AlgoliaDocSearchSite.cs | 35 +++ .../AlgoliaDocumentationSource.cs | 189 +++++++++++++++++ .../CachingDocumentationSource.cs | 90 ++++++++ .../DefaultDocumentationSourceProvider.cs | 38 ++++ .../Documentation/DocumentationCorpus.cs | 185 ++++++++++++++++ .../DocumentationSearchBuilder.cs | 65 ++++++ .../DocumentationSearchIndexSite.cs | 32 +++ .../DocumentationSearchOptions.cs | 12 ++ .../SearchIndexDocumentationSource.cs | 120 +++++++++++ .../SitemapDocumentationSource.cs | 199 +----------------- .../Core/Mcp/DocumentationSearchTests.cs | 55 +++++ 13 files changed, 911 insertions(+), 192 deletions(-) create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocSearchSite.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSource.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/CachingDocumentationSource.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationCorpus.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchIndexSite.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSource.cs diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index 1219a655..56b1317a 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -42,6 +42,12 @@ page will be updated as changes land after 1.0.0. `DocumentationSearchOptions`, and custom sources can be plugged in by implementing `IDocumentationSource`. The tool is registered under the `knowledgebase` category so a read-only knowledge-base MCP server can expose it selectively with `WithToolsInCategory("knowledgebase")` +- adds two additional documentation search strategies alongside the sitemap crawler, each with its own + builder method and configuration model: `AddSearchIndex(...)` downloads a prebuilt search index + published as JSON (for example a MkDocs Material `search_index.json`) and ranks it locally, and + `AddAlgoliaDocSearch(...)` forwards queries to the hosted Algolia DocSearch API used by many + Docusaurus sites. Both also bind from configuration through the new `SearchIndexes` and + `AlgoliaSources` lists on `DocumentationSearchOptions` ## Fixes diff --git a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md index c79384a4..d9bb1b25 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md +++ b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md @@ -88,11 +88,13 @@ sitemap, so no generator-specific configuration is required. | Property | Default | Description | |----------|---------|-------------| -| `Sites` | empty | The public documentation sites the crawler scans. | +| `Sites` | empty | The public documentation sites the crawler scans through their sitemap. | +| `SearchIndexes` | empty | The documentation sites that publish a prebuilt search index as JSON. | +| `AlgoliaSources` | empty | The documentation sites searchable through the Algolia DocSearch API. | | `MaxResultsPerSite` | `5` | Default maximum results a single site contributes to a search. | | `MaxPagesPerSite` | `200` | Default maximum pages the crawler indexes per site. | | `MaxConcurrentRequests` | `4` | Maximum concurrent page requests per site while crawling. | -| `CacheDuration` | `1 hour` | How long a crawled site corpus is cached before it is refreshed. | +| `CacheDuration` | `1 hour` | How long a crawled or downloaded site corpus is cached before it is refreshed. | ### DocumentationSite @@ -107,6 +109,74 @@ sitemap, so no generator-specific configuration is required. The first search against a site crawls its pages and caches the corpus in memory for `CacheDuration`; subsequent searches reuse the cache. Ranking uses lightweight keyword scoring. +## Search Strategies + +A single documentation site can be indexed in different ways depending on what the generator publishes. +Each strategy has its own builder method and configuration model, so you choose the one that matches the +site — there is no single "kind" switch to configure. + +| Strategy | Builder method | Best for | How it works | +|----------|----------------|----------|--------------| +| Sitemap crawl | `AddSite(...)` | Any site that publishes `sitemap.xml` (Docusaurus, MkDocs, and most static sites). | Crawls pages, strips HTML, and ranks locally with keyword scoring. | +| Search index | `AddSearchIndex(...)` | MkDocs Material and other sites that publish a fetchable `search_index.json`. | Downloads the prebuilt index once and ranks its entries locally. | +| Algolia DocSearch | `AddAlgoliaDocSearch(...)` | Docusaurus sites (and others) wired to hosted Algolia DocSearch. | Forwards the query to Algolia, which performs the ranking. | + +### Search Index Source + +MkDocs Material publishes a fetchable `search_index.json`. `AddSearchIndex(...)` downloads that index +once, caches it for `CacheDuration`, and ranks its entries with the same keyword scoring as the crawler +— without fetching every page individually. + +```csharp +.AddDocumentationSearch(docs => docs + .AddSearchIndex("mkdocs", "https://www.mkdocs.org", site => + { + // Optional. Defaults to {BaseUrl}/search/search_index.json. + site.IndexUrl = "https://www.mkdocs.org/search/search_index.json"; + site.MaxResults = 5; + })) +``` + +| `DocumentationSearchIndexSite` property | Description | +|----------|-------------| +| `Name` | Unique logical name; a caller can scope a search to this source. | +| `BaseUrl` | Base URL used to resolve relative entry locations and the default index URL. | +| `IndexUrl` | Optional explicit index URL. Defaults to `{BaseUrl}/search/search_index.json`. | +| `MaxResults` | Optional per-site override for the maximum results. | + +:::note +This targets the MkDocs Material `search_index.json` schema (`{ "docs": [ { "location", "title", "text" } ] }`). +Docusaurus' `@easyops-cn/docusaurus-search-local` plugin stores a client-side Lunr index that is not a +cleanly fetchable JSON document, so use the sitemap crawl or Algolia DocSearch for Docusaurus sites. +::: + +### Algolia DocSearch Source + +Many Docusaurus sites use hosted Algolia DocSearch rather than a fetchable index. `AddAlgoliaDocSearch(...)` +forwards each query to the Algolia query API and maps the returned hits to results. Because Algolia +performs the ranking, this source issues a live query per search and does not crawl or cache a corpus. + +```csharp +.AddDocumentationSearch(docs => docs + .AddAlgoliaDocSearch( + name: "docusaurus", + applicationId: "YOUR_APP_ID", + apiKey: "YOUR_SEARCH_ONLY_API_KEY", + indexName: "your-index", + site => site.MaxResults = 5)) +``` + +| `AlgoliaDocSearchSite` property | Description | +|----------|-------------| +| `Name` | Unique logical name; a caller can scope a search to this source. | +| `ApplicationId` | Algolia application identifier. | +| `ApiKey` | Algolia **search-only** API key (never a write key). | +| `IndexName` | Algolia index name to query. | +| `MaxResults` | Optional per-site override for the maximum results. | + +Each strategy also binds from configuration through the matching `SearchIndexes` and `AlgoliaSources` +lists on `DocumentationSearchOptions`, mirroring the `Sites` list shown above. + ## Custom Sources Implement `IDocumentationSource` to search anything that is not a public sitemap-based site (for @@ -141,7 +211,8 @@ passing a `source` argument scopes the search to that single named source. 1. `AddCoreAIDocumentationSearch(...)` registers the `search_documentation` tool, the `DefaultDocumentationSourceProvider`, and a named `HttpClient` with standard resilience. 2. When invoked, the tool resolves `IDocumentationSourceProvider` to get all sources (custom sources - plus a `SitemapDocumentationSource` per configured site). + plus a `SitemapDocumentationSource`, `SearchIndexDocumentationSource`, or `AlgoliaDocumentationSource` + per configured site, depending on the builder method used). 3. Each source is searched in parallel; a failing source is skipped so one broken site does not fail the whole search. 4. Results are merged, ordered by descending relevance, and returned with their titles and URLs so the diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocSearchSite.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocSearchSite.cs new file mode 100644 index 00000000..3efa606d --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocSearchSite.cs @@ -0,0 +1,35 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Describes a documentation site that is searchable through the Algolia DocSearch query API (the +/// hosted search used by many Docusaurus sites). The built-in source forwards queries to Algolia and +/// maps the returned hits to documentation results without crawling or caching a corpus locally. +/// +public sealed class AlgoliaDocSearchSite +{ + /// + /// Gets or sets the unique logical name of the site. + /// + public string Name { get; set; } + + /// + /// Gets or sets the Algolia application identifier. + /// + public string ApplicationId { get; set; } + + /// + /// Gets or sets the Algolia search-only API key. + /// + public string ApiKey { get; set; } + + /// + /// Gets or sets the Algolia index name to query. + /// + public string IndexName { get; set; } + + /// + /// Gets or sets the maximum number of results this site should contribute to a search. When not + /// set, the global value is used. + /// + public int? MaxResults { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSource.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSource.cs new file mode 100644 index 00000000..b1d4b1d4 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSource.cs @@ -0,0 +1,189 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// A built-in that searches a documentation site through the +/// Algolia DocSearch query API. Queries are forwarded to Algolia, which performs the ranking, and the +/// returned hits are mapped to documentation results. This source does not crawl or cache a corpus +/// locally; each search issues a live query. +/// +public sealed class AlgoliaDocumentationSource : IDocumentationSource +{ + private readonly AlgoliaDocSearchSite _site; + private readonly DocumentationSearchOptions _options; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The site configuration. + /// The global documentation search options. + /// The HTTP client factory. + /// The logger. + public AlgoliaDocumentationSource( + AlgoliaDocSearchSite site, + DocumentationSearchOptions options, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _site = site; + _options = options; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + /// + public string Name => _site.Name; + + /// + public async Task> SearchAsync(DocumentationSearchRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(request.Query)) + { + return []; + } + + var maxResults = Math.Min(request.MaxResults, _site.MaxResults ?? _options.MaxResultsPerSite); + + if (maxResults <= 0) + { + return []; + } + + try + { + var hits = await QueryAsync(request.Query, maxResults, cancellationToken); + + if (hits.Count == 0) + { + return []; + } + + var results = new List(hits.Count); + + for (var i = 0; i < hits.Count; i++) + { + var hit = hits[i]; + + if (string.IsNullOrWhiteSpace(hit.Url)) + { + continue; + } + + results.Add(new DocumentationSearchResult + { + SourceName = Name, + Title = ResolveTitle(hit), + Url = hit.Url, + Snippet = hit.Content, + Score = hits.Count - i, + }); + } + + return results; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to query Algolia DocSearch for documentation source '{SourceName}'.", _site.Name); + + return []; + } + } + + private async Task> QueryAsync(string query, int maxResults, CancellationToken cancellationToken) + { + var client = _httpClientFactory.CreateClient(McpConstants.DocumentationHttpClientName); + + var requestUri = $"https://{_site.ApplicationId}-dsn.algolia.net/1/indexes/{Uri.EscapeDataString(_site.IndexName)}/query"; + var parameters = $"query={Uri.EscapeDataString(query)}&hitsPerPage={maxResults}"; + + using var message = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = JsonContent.Create(new AlgoliaQueryRequest { Params = parameters }), + }; + + message.Headers.TryAddWithoutValidation("X-Algolia-Application-Id", _site.ApplicationId); + message.Headers.TryAddWithoutValidation("X-Algolia-API-Key", _site.ApiKey); + message.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + using var response = await client.SendAsync(message, cancellationToken); + + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + + return payload?.Hits ?? []; + } + + private static string ResolveTitle(AlgoliaHit hit) + { + if (hit.Hierarchy is not null) + { + foreach (var level in new[] { hit.Hierarchy.Lvl6, hit.Hierarchy.Lvl5, hit.Hierarchy.Lvl4, hit.Hierarchy.Lvl3, hit.Hierarchy.Lvl2, hit.Hierarchy.Lvl1, hit.Hierarchy.Lvl0 }) + { + if (!string.IsNullOrWhiteSpace(level)) + { + return level; + } + } + } + + return hit.Url; + } + + private sealed class AlgoliaQueryRequest + { + [JsonPropertyName("params")] + public string Params { get; set; } + } + + private sealed class AlgoliaQueryResponse + { + [JsonPropertyName("hits")] + public IReadOnlyList Hits { get; set; } + } + + private sealed class AlgoliaHit + { + [JsonPropertyName("url")] + public string Url { get; set; } + + [JsonPropertyName("content")] + public string Content { get; set; } + + [JsonPropertyName("hierarchy")] + public AlgoliaHierarchy Hierarchy { get; set; } + } + + private sealed class AlgoliaHierarchy + { + [JsonPropertyName("lvl0")] + public string Lvl0 { get; set; } + + [JsonPropertyName("lvl1")] + public string Lvl1 { get; set; } + + [JsonPropertyName("lvl2")] + public string Lvl2 { get; set; } + + [JsonPropertyName("lvl3")] + public string Lvl3 { get; set; } + + [JsonPropertyName("lvl4")] + public string Lvl4 { get; set; } + + [JsonPropertyName("lvl5")] + public string Lvl5 { get; set; } + + [JsonPropertyName("lvl6")] + public string Lvl6 { get; set; } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/CachingDocumentationSource.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/CachingDocumentationSource.cs new file mode 100644 index 00000000..b60d04ce --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/CachingDocumentationSource.cs @@ -0,0 +1,90 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// A base class for documentation sources that materialize a full +/// and answer queries from it. The corpus is built lazily on first use and cached in memory until it +/// expires, so derived sources only implement how the corpus is produced. +/// +public abstract class CachingDocumentationSource : IDocumentationSource +{ + private readonly TimeSpan _cacheDuration; + private readonly TimeProvider _timeProvider; + private readonly SemaphoreSlim _loadLock = new(1, 1); + + private DocumentationCorpus _corpus; + private DateTimeOffset _loadedAt = DateTimeOffset.MinValue; + + /// + /// Initializes a new instance of the class. + /// + /// The unique logical name of the source. + /// How long the built corpus is cached before it is refreshed. + /// The time provider. + protected CachingDocumentationSource(string name, TimeSpan cacheDuration, TimeProvider timeProvider) + { + Name = name; + _cacheDuration = cacheDuration; + _timeProvider = timeProvider; + } + + /// + public string Name { get; } + + /// + /// Gets the maximum number of results this source contributes to a search. + /// + protected abstract int MaxResults { get; } + + /// + /// Builds the documentation corpus that backs this source. + /// + /// The cancellation token. + /// The materialized corpus. + protected abstract Task BuildCorpusAsync(CancellationToken cancellationToken); + + /// + public async Task> SearchAsync(DocumentationSearchRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(request.Query)) + { + return []; + } + + var corpus = await GetCorpusAsync(cancellationToken); + + return corpus.Search(request.Query, Name, Math.Min(request.MaxResults, MaxResults)); + } + + private async Task GetCorpusAsync(CancellationToken cancellationToken) + { + var now = _timeProvider.GetUtcNow(); + + if (_corpus is not null && now - _loadedAt < _cacheDuration) + { + return _corpus; + } + + await _loadLock.WaitAsync(cancellationToken); + + try + { + now = _timeProvider.GetUtcNow(); + + if (_corpus is not null && now - _loadedAt < _cacheDuration) + { + return _corpus; + } + + _corpus = await BuildCorpusAsync(cancellationToken); + _loadedAt = _timeProvider.GetUtcNow(); + + return _corpus; + } + finally + { + _loadLock.Release(); + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs index 98ea237c..576b1409 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs @@ -18,6 +18,8 @@ public sealed class DefaultDocumentationSourceProvider : IDocumentationSourcePro private readonly ILoggerFactory _loggerFactory; private readonly TimeProvider _timeProvider; private readonly ConcurrentDictionary _siteSources = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _searchIndexSources = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _algoliaSources = new(StringComparer.OrdinalIgnoreCase); /// /// Initializes a new instance of the class. @@ -64,6 +66,42 @@ public IReadOnlyList GetSources() sources.Add(source); } + foreach (var site in options.SearchIndexes) + { + if (string.IsNullOrWhiteSpace(site.Name) || string.IsNullOrWhiteSpace(site.BaseUrl)) + { + continue; + } + + var source = _searchIndexSources.GetOrAdd(site.Name, _ => new SearchIndexDocumentationSource( + site, + options, + _httpClientFactory, + _timeProvider, + _loggerFactory.CreateLogger())); + + sources.Add(source); + } + + foreach (var site in options.AlgoliaSources) + { + if (string.IsNullOrWhiteSpace(site.Name) + || string.IsNullOrWhiteSpace(site.ApplicationId) + || string.IsNullOrWhiteSpace(site.ApiKey) + || string.IsNullOrWhiteSpace(site.IndexName)) + { + continue; + } + + var source = _algoliaSources.GetOrAdd(site.Name, _ => new AlgoliaDocumentationSource( + site, + options, + _httpClientFactory, + _loggerFactory.CreateLogger())); + + sources.Add(source); + } + return sources; } } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationCorpus.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationCorpus.cs new file mode 100644 index 00000000..11a1841e --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationCorpus.cs @@ -0,0 +1,185 @@ +using System.Text.RegularExpressions; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// An in-memory, keyword-searchable collection of documentation entries. Sources that materialize a +/// full corpus (for example a crawled site or a downloaded search index) build a +/// once and reuse it to answer queries with lightweight keyword +/// scoring, keeping the ranking behavior consistent across sources. +/// +public sealed partial class DocumentationCorpus +{ + private readonly IReadOnlyList _entries; + + /// + /// Initializes a new instance of the class. + /// + /// The documentation entries that make up the corpus. + public DocumentationCorpus(IReadOnlyList entries) + { + _entries = entries ?? []; + } + + /// + /// Gets the number of entries in the corpus. + /// + public int Count => _entries.Count; + + /// + /// Searches the corpus for entries relevant to the supplied query. + /// + /// The free-text query. + /// The logical name of the source that owns this corpus. + /// The maximum number of results to return. + /// The relevant entries ordered by descending relevance. + public IReadOnlyList Search(string query, string sourceName, int maxResults) + { + if (_entries.Count == 0 || string.IsNullOrWhiteSpace(query) || maxResults <= 0) + { + return []; + } + + var terms = Tokenize(query); + + if (terms.Length == 0) + { + return []; + } + + var scored = new List(); + + foreach (var entry in _entries) + { + var score = Score(entry, terms); + + if (score <= 0) + { + continue; + } + + scored.Add(new DocumentationSearchResult + { + SourceName = sourceName, + Title = string.IsNullOrWhiteSpace(entry.Title) ? entry.Url : entry.Title, + Url = entry.Url, + Snippet = BuildSnippet(entry.Text, terms), + Score = score, + }); + } + + return scored + .OrderByDescending(result => result.Score) + .Take(maxResults) + .ToList(); + } + + private static double Score(Entry entry, string[] terms) + { + double score = 0; + var matchedTerms = 0; + + foreach (var term in terms) + { + var bodyCount = CountOccurrences(entry.Text, term); + var titleCount = CountOccurrences(entry.Title, term); + + if (bodyCount == 0 && titleCount == 0) + { + continue; + } + + matchedTerms++; + score += bodyCount + (titleCount * 5); + } + + if (matchedTerms == 0) + { + return 0; + } + + return score * matchedTerms; + } + + private static int CountOccurrences(string text, string term) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + var count = 0; + var index = 0; + + while ((index = text.IndexOf(term, index, StringComparison.OrdinalIgnoreCase)) >= 0) + { + count++; + index += term.Length; + } + + return count; + } + + private static string BuildSnippet(string text, string[] terms) + { + const int windowSize = 240; + + if (string.IsNullOrEmpty(text)) + { + return null; + } + + var matchIndex = -1; + + foreach (var term in terms) + { + var index = text.IndexOf(term, StringComparison.OrdinalIgnoreCase); + + if (index >= 0 && (matchIndex < 0 || index < matchIndex)) + { + matchIndex = index; + } + } + + if (matchIndex < 0) + { + return text.Length <= windowSize ? text : text[..windowSize] + "…"; + } + + var start = Math.Max(0, matchIndex - (windowSize / 2)); + var length = Math.Min(windowSize, text.Length - start); + var snippet = text.Substring(start, length).Trim(); + + if (start > 0) + { + snippet = "…" + snippet; + } + + if (start + length < text.Length) + { + snippet += "…"; + } + + return snippet; + } + + private static string[] Tokenize(string query) + { + return NonWordRegex() + .Split(query.ToLowerInvariant()) + .Where(token => token.Length >= 2) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + [GeneratedRegex(@"[^a-z0-9]+")] + private static partial Regex NonWordRegex(); + + /// + /// Represents a single documentation entry that can be searched. + /// + /// The canonical URL of the entry. + /// The title of the entry. + /// The plain-text body of the entry. + public sealed record Entry(string Url, string Title, string Text); +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchBuilder.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchBuilder.cs index 5fec43bd..bfe11e27 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchBuilder.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchBuilder.cs @@ -53,6 +53,71 @@ public DocumentationSearchBuilder AddSite(string name, string baseUrl, Action + /// Registers a documentation site that publishes a prebuilt search index as JSON (for example a + /// MkDocs Material search_index.json). The built-in source downloads the index once and + /// ranks its entries with keyword scoring. + /// + /// The unique logical name of the site. + /// The base URL of the documentation site, used to resolve result URLs. + /// An optional action used to further configure the site. + /// The same builder instance for chaining. + public DocumentationSearchBuilder AddSearchIndex(string name, string baseUrl, Action configure = null) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(baseUrl); + + Services.Configure(options => + { + var site = new DocumentationSearchIndexSite + { + Name = name, + BaseUrl = baseUrl, + }; + + configure?.Invoke(site); + + options.SearchIndexes.Add(site); + }); + + return this; + } + + /// + /// Registers a documentation site that is searchable through the Algolia DocSearch query API (the + /// hosted search used by many Docusaurus sites). + /// + /// The unique logical name of the site. + /// The Algolia application identifier. + /// The Algolia search-only API key. + /// The Algolia index name to query. + /// An optional action used to further configure the site. + /// The same builder instance for chaining. + public DocumentationSearchBuilder AddAlgoliaDocSearch(string name, string applicationId, string apiKey, string indexName, Action configure = null) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(applicationId); + ArgumentException.ThrowIfNullOrEmpty(apiKey); + ArgumentException.ThrowIfNullOrEmpty(indexName); + + Services.Configure(options => + { + var site = new AlgoliaDocSearchSite + { + Name = name, + ApplicationId = applicationId, + ApiKey = apiKey, + IndexName = indexName, + }; + + configure?.Invoke(site); + + options.AlgoliaSources.Add(site); + }); + + return this; + } + /// /// Registers a custom documentation source implementation. /// diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchIndexSite.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchIndexSite.cs new file mode 100644 index 00000000..6baacb6e --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchIndexSite.cs @@ -0,0 +1,32 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Describes a documentation site that publishes a prebuilt search index as JSON (for example a MkDocs +/// Material search_index.json). The built-in source downloads the index once, ranks its entries +/// with keyword scoring, and resolves each result URL relative to . +/// +public sealed class DocumentationSearchIndexSite +{ + /// + /// Gets or sets the unique logical name of the site. + /// + public string Name { get; set; } + + /// + /// Gets or sets the base URL of the documentation site. It is used to resolve relative entry + /// locations and, when is not set, to derive the default index URL. + /// + public string BaseUrl { get; set; } + + /// + /// Gets or sets an explicit URL to the search index JSON. When not set, the source resolves it from + /// by appending /search/search_index.json. + /// + public string IndexUrl { get; set; } + + /// + /// Gets or sets the maximum number of results this site should contribute to a search. When not + /// set, the global value is used. + /// + public int? MaxResults { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs index 88f04f37..207098e2 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs @@ -12,6 +12,18 @@ public sealed class DocumentationSearchOptions /// public IList Sites { get; } = []; + /// + /// Gets the collection of documentation sites that expose a prebuilt search index as JSON (for + /// example MkDocs Material) that the built-in search-index source downloads and ranks. + /// + public IList SearchIndexes { get; } = []; + + /// + /// Gets the collection of documentation sites that are searchable through the Algolia DocSearch + /// query API. + /// + public IList AlgoliaSources { get; } = []; + /// /// Gets or sets the default maximum number of results a single site contributes to a search. /// diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSource.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSource.cs new file mode 100644 index 00000000..a40e0112 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSource.cs @@ -0,0 +1,120 @@ +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// A built-in that indexes a documentation site by downloading a +/// prebuilt search index published as JSON (for example a MkDocs Material search_index.json), +/// then ranks its entries against a query using lightweight keyword scoring. The downloaded corpus is +/// cached in memory and refreshed based on . +/// +public sealed class SearchIndexDocumentationSource : CachingDocumentationSource +{ + private readonly DocumentationSearchIndexSite _site; + private readonly DocumentationSearchOptions _options; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The site configuration. + /// The global documentation search options. + /// The HTTP client factory. + /// The time provider. + /// The logger. + public SearchIndexDocumentationSource( + DocumentationSearchIndexSite site, + DocumentationSearchOptions options, + IHttpClientFactory httpClientFactory, + TimeProvider timeProvider, + ILogger logger) + : base(site.Name, options.CacheDuration, timeProvider) + { + _site = site; + _options = options; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + /// + protected override int MaxResults => _site.MaxResults ?? _options.MaxResultsPerSite; + + /// + protected override async Task BuildCorpusAsync(CancellationToken cancellationToken) + { + var indexUrl = ResolveIndexUrl(); + + try + { + var client = _httpClientFactory.CreateClient(McpConstants.DocumentationHttpClientName); + var index = await client.GetFromJsonAsync(indexUrl, cancellationToken); + + if (index?.Docs is null || index.Docs.Count == 0) + { + return new DocumentationCorpus([]); + } + + var entries = new List(index.Docs.Count); + + foreach (var doc in index.Docs) + { + if (string.IsNullOrWhiteSpace(doc.Location) || string.IsNullOrWhiteSpace(doc.Text)) + { + continue; + } + + entries.Add(new DocumentationCorpus.Entry(ResolveUrl(doc.Location), doc.Title, doc.Text)); + } + + return new DocumentationCorpus(entries); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read search index '{IndexUrl}' for documentation source '{SourceName}'.", indexUrl, _site.Name); + + return new DocumentationCorpus([]); + } + } + + private string ResolveIndexUrl() + { + if (!string.IsNullOrWhiteSpace(_site.IndexUrl)) + { + return _site.IndexUrl; + } + + return $"{_site.BaseUrl.TrimEnd('/')}/search/search_index.json"; + } + + private string ResolveUrl(string location) + { + if (Uri.TryCreate(location, UriKind.Absolute, out _)) + { + return location; + } + + return $"{_site.BaseUrl.TrimEnd('/')}/{location.TrimStart('/')}"; + } + + private sealed class SearchIndexDocument + { + [JsonPropertyName("docs")] + public IReadOnlyList Docs { get; set; } + } + + private sealed class SearchIndexEntry + { + [JsonPropertyName("location")] + public string Location { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("text")] + public string Text { get; set; } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSource.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSource.cs index 6b2fc9da..373fd778 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSource.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSource.cs @@ -11,17 +11,12 @@ namespace CrestApps.Core.AI.Mcp.Documentation; /// keyword scoring. The crawled corpus is cached in memory and refreshed based on /// . /// -public sealed partial class SitemapDocumentationSource : IDocumentationSource +public sealed partial class SitemapDocumentationSource : CachingDocumentationSource { private readonly DocumentationSite _site; private readonly DocumentationSearchOptions _options; private readonly IHttpClientFactory _httpClientFactory; - private readonly TimeProvider _timeProvider; private readonly ILogger _logger; - private readonly SemaphoreSlim _loadLock = new(1, 1); - - private IReadOnlyList _pages = []; - private DateTimeOffset _loadedAt = DateTimeOffset.MinValue; /// /// Initializes a new instance of the class. @@ -37,102 +32,26 @@ public SitemapDocumentationSource( IHttpClientFactory httpClientFactory, TimeProvider timeProvider, ILogger logger) + : base(site.Name, options.CacheDuration, timeProvider) { _site = site; _options = options; _httpClientFactory = httpClientFactory; - _timeProvider = timeProvider; _logger = logger; } /// - public string Name => _site.Name; + protected override int MaxResults => _site.MaxResults ?? _options.MaxResultsPerSite; /// - public async Task> SearchAsync(DocumentationSearchRequest request, CancellationToken cancellationToken) + protected override async Task BuildCorpusAsync(CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(request); - - if (string.IsNullOrWhiteSpace(request.Query)) - { - return []; - } - - var pages = await GetPagesAsync(cancellationToken); - - if (pages.Count == 0) - { - return []; - } - - var terms = Tokenize(request.Query); - - if (terms.Length == 0) - { - return []; - } - - var maxResults = _site.MaxResults ?? _options.MaxResultsPerSite; - - var scored = new List(); - - foreach (var page in pages) - { - var score = Score(page, terms); - - if (score <= 0) - { - continue; - } + var entries = await CrawlAsync(cancellationToken); - scored.Add(new DocumentationSearchResult - { - SourceName = _site.Name, - Title = string.IsNullOrWhiteSpace(page.Title) ? page.Url : page.Title, - Url = page.Url, - Snippet = BuildSnippet(page.Text, terms), - Score = score, - }); - } - - return scored - .OrderByDescending(result => result.Score) - .Take(Math.Min(request.MaxResults, maxResults)) - .ToList(); + return new DocumentationCorpus(entries); } - private async Task> GetPagesAsync(CancellationToken cancellationToken) - { - var now = _timeProvider.GetUtcNow(); - - if (_pages.Count > 0 && now - _loadedAt < _options.CacheDuration) - { - return _pages; - } - - await _loadLock.WaitAsync(cancellationToken); - - try - { - now = _timeProvider.GetUtcNow(); - - if (_pages.Count > 0 && now - _loadedAt < _options.CacheDuration) - { - return _pages; - } - - _pages = await CrawlAsync(cancellationToken); - _loadedAt = _timeProvider.GetUtcNow(); - - return _pages; - } - finally - { - _loadLock.Release(); - } - } - - private async Task> CrawlAsync(CancellationToken cancellationToken) + private async Task> CrawlAsync(CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient(McpConstants.DocumentationHttpClientName); var urls = await GetSitemapUrlsAsync(client, cancellationToken); @@ -149,7 +68,7 @@ private async Task> CrawlAsync(CancellationToke urls = urls.Take(maxPages).ToList(); } - var pages = new List(urls.Count); + var pages = new List(urls.Count); using var throttle = new SemaphoreSlim(Math.Max(1, _options.MaxConcurrentRequests)); var tasks = urls.Select(async url => @@ -203,7 +122,7 @@ private async Task> GetSitemapUrlsAsync(HttpClient client, } } - private async Task FetchPageAsync(HttpClient client, string url, CancellationToken cancellationToken) + private async Task FetchPageAsync(HttpClient client, string url, CancellationToken cancellationToken) { try { @@ -216,7 +135,7 @@ private async Task FetchPageAsync(HttpClient client, string u return null; } - return new DocumentationPage(url, title, text); + return new DocumentationCorpus.Entry(url, title, text); } catch (Exception ex) { @@ -239,99 +158,6 @@ private string ResolveSitemapUrl() return $"{_site.BaseUrl.TrimEnd('/')}/sitemap.xml"; } - private static double Score(DocumentationPage page, string[] terms) - { - double score = 0; - var matchedTerms = 0; - - foreach (var term in terms) - { - var bodyCount = CountOccurrences(page.Text, term); - var titleCount = CountOccurrences(page.Title, term); - - if (bodyCount == 0 && titleCount == 0) - { - continue; - } - - matchedTerms++; - score += bodyCount + (titleCount * 5); - } - - if (matchedTerms == 0) - { - return 0; - } - - return score * matchedTerms; - } - - private static int CountOccurrences(string text, string term) - { - if (string.IsNullOrEmpty(text)) - { - return 0; - } - - var count = 0; - var index = 0; - - while ((index = text.IndexOf(term, index, StringComparison.OrdinalIgnoreCase)) >= 0) - { - count++; - index += term.Length; - } - - return count; - } - - private static string BuildSnippet(string text, string[] terms) - { - const int windowSize = 240; - - var matchIndex = -1; - - foreach (var term in terms) - { - var index = text.IndexOf(term, StringComparison.OrdinalIgnoreCase); - - if (index >= 0 && (matchIndex < 0 || index < matchIndex)) - { - matchIndex = index; - } - } - - if (matchIndex < 0) - { - return text.Length <= windowSize ? text : text[..windowSize] + "…"; - } - - var start = Math.Max(0, matchIndex - (windowSize / 2)); - var length = Math.Min(windowSize, text.Length - start); - var snippet = text.Substring(start, length).Trim(); - - if (start > 0) - { - snippet = "…" + snippet; - } - - if (start + length < text.Length) - { - snippet += "…"; - } - - return snippet; - } - - private static string[] Tokenize(string query) - { - return NonWordRegex() - .Split(query.ToLowerInvariant()) - .Where(token => token.Length >= 2) - .Distinct(StringComparer.Ordinal) - .ToArray(); - } - private static string ExtractTitle(string html) { var match = TitleRegex().Match(html); @@ -368,9 +194,4 @@ private static string ExtractText(string html) [GeneratedRegex(@"\s+")] private static partial Regex WhitespaceRegex(); - - [GeneratedRegex(@"[^a-z0-9]+")] - private static partial Regex NonWordRegex(); - - private sealed record DocumentationPage(string Url, string Title, string Text); } diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs index 9dee17fe..fd7ce496 100644 --- a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs @@ -53,6 +53,57 @@ public void AddSite_PopulatesOptions() Assert.Equal(3, site.MaxResults); } + /// + /// Verifies that records the search-index + /// site in the options. + /// + [Fact] + public void AddSearchIndex_PopulatesOptions() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCoreAIDocumentationSearch(docs => docs + .AddSearchIndex("mkdocs", "https://docs.example.com", site => + { + site.IndexUrl = "https://docs.example.com/search/search_index.json"; + site.MaxResults = 4; + })); + + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + var site = Assert.Single(options.SearchIndexes); + + Assert.Equal("mkdocs", site.Name); + Assert.Equal("https://docs.example.com", site.BaseUrl); + Assert.Equal("https://docs.example.com/search/search_index.json", site.IndexUrl); + Assert.Equal(4, site.MaxResults); + } + + /// + /// Verifies that records the Algolia + /// site in the options. + /// + [Fact] + public void AddAlgoliaDocSearch_PopulatesOptions() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCoreAIDocumentationSearch(docs => docs + .AddAlgoliaDocSearch("algolia", "APP123", "search-key", "docs-index", site => site.MaxResults = 6)); + + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + var site = Assert.Single(options.AlgoliaSources); + + Assert.Equal("algolia", site.Name); + Assert.Equal("APP123", site.ApplicationId); + Assert.Equal("search-key", site.ApiKey); + Assert.Equal("docs-index", site.IndexName); + Assert.Equal(6, site.MaxResults); + } + /// /// Verifies that the source provider aggregates code-registered custom sources with the built-in /// crawler sources materialized from the configured sites. @@ -64,6 +115,8 @@ public void SourceProvider_AggregatesCustomAndConfiguredSites() services.AddLogging(); services.AddCoreAIDocumentationSearch(docs => docs .AddSite("site-1", "https://docs.example.com") + .AddSearchIndex("index-1", "https://mkdocs.example.com") + .AddAlgoliaDocSearch("algolia-1", "APP123", "search-key", "docs-index") .AddSource(new FakeDocumentationSource("custom-1"))); using var provider = services.BuildServiceProvider(); @@ -72,6 +125,8 @@ public void SourceProvider_AggregatesCustomAndConfiguredSites() Assert.Contains(sources, source => source.Name == "custom-1"); Assert.Contains(sources, source => source.Name == "site-1"); + Assert.Contains(sources, source => source.Name == "index-1"); + Assert.Contains(sources, source => source.Name == "algolia-1"); } /// From e224da50fb3f5bfc83e9c97722fc74da9de051d3 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 9 Aug 2026 20:23:22 +0300 Subject: [PATCH 06/13] Document registering a public Docusaurus site as a search source Adds a worked example (using core.crestapps.com) for registering an unauthenticated Docusaurus documentation site with the sitemap crawl strategy, including code, MCP server builder, options tuning, and configuration-binding variants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/mcp/documentation-search.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md index d9bb1b25..fa801ea5 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md +++ b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md @@ -121,6 +121,69 @@ site — there is no single "kind" switch to configure. | Search index | `AddSearchIndex(...)` | MkDocs Material and other sites that publish a fetchable `search_index.json`. | Downloads the prebuilt index once and ranks its entries locally. | | Algolia DocSearch | `AddAlgoliaDocSearch(...)` | Docusaurus sites (and others) wired to hosted Algolia DocSearch. | Forwards the query to Algolia, which performs the ranking. | +### Example: a public Docusaurus site + +A public Docusaurus site that requires no authentication — such as +[core.crestapps.com](https://core.crestapps.com) — only needs the sitemap crawl strategy. Docusaurus +publishes a standard `sitemap.xml` at the site root, so `AddSite(...)` is all that is required: give the +source a logical name and the site's base URL, and the crawler discovers `{BaseUrl}/sitemap.xml` +automatically. + +```csharp +builder.Services.AddCoreAIDocumentationSearch(docs => docs + .AddSite("crestapps-core", "https://core.crestapps.com")); +``` + +Or on the MCP server builder for a read-only knowledge-base server: + +```csharp +builder.Services.AddCrestAppsCore(crestApps => crestApps + .AddAISuite(ai => ai + .AddMcpServer(mcpServer => mcpServer + .AddYesSqlStores() + .AddDocumentationSearch(docs => docs + .AddSite("crestapps-core", "https://core.crestapps.com") + ) + ) + ) +); +``` + +You can tune how much of the site is indexed and scope results with the optional `configure` action: + +```csharp +.AddDocumentationSearch(docs => docs + .AddSite("crestapps-core", "https://core.crestapps.com", site => + { + // Only needed if the sitemap is not at {BaseUrl}/sitemap.xml. + site.SitemapUrl = "https://core.crestapps.com/sitemap.xml"; + site.MaxPages = 300; // Cap the number of pages crawled. + site.MaxResults = 5; // Cap the results this site contributes per search. + })) +``` + +The same site can also be declared in configuration instead of code: + +```json +{ + "DocumentationSearch": { + "Sites": [ + { "Name": "crestapps-core", "BaseUrl": "https://core.crestapps.com" } + ] + } +} +``` + +Because the site is public, no headers, API keys, or credentials are involved — the crawler issues +plain anonymous `GET` requests through the source's resilient `HttpClient`. The first search crawls the +site and caches the corpus for `CacheDuration`; later searches reuse the cache. + +:::tip +Prefer the sitemap crawl for a public Docusaurus site. Only reach for `AddAlgoliaDocSearch(...)` when +the site is wired to hosted Algolia DocSearch and you have its application ID, search-only API key, and +index name. +::: + ### Search Index Source MkDocs Material publishes a fetchable `search_index.json`. `AddSearchIndex(...)` downloads that index From 688ed295789c5b8cdee2868b7fd674a79f46608f Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 9 Aug 2026 21:06:29 +0300 Subject: [PATCH 07/13] Add store-backed documentation sources and MCP server capability options Documentation search: - Persist documentation sources as DocumentationSourceEntry catalog entries so they can be added, edited, and removed at runtime via a UI or database, and aggregate them with options-defined and custom sources. - Add IDocumentationSourceFactory strategy factories (sitemap, search-index, algolia) and DocumentationSourceStrategies, materializing both options-defined and stored entries through the factory set with signature-based caching. - Add DefaultDocumentationSourceCatalog (multi-source), the catalog handler (validation + defaults), and manager registration; add YesSql index/schema and EntityCore binding source with AddYesSqlStores()/AddEntityCoreStores() builder methods. - Rework IDocumentationSourceProvider to async GetSourcesAsync(IServiceProvider, CancellationToken) so the tool passes its request scope to the singleton provider to resolve the scoped catalog and custom sources (unreleased 1.1.0 interface, not a breaking change against 1.0.0). MCP server capabilities: - Add McpServerHandlerOptions and a WithCrestAppsHandlers(IConfiguration, ...) overload so IncludeTools/IncludeSdkTools/IncludePrompts/IncludeResources can be toggled from configuration; configuration wins over code, bound eagerly at registration time. Docs and tests updated; docs site builds; full test suite passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/changelog/1.1.0.md | 22 ++ .../docs/mcp/documentation-search.md | 75 +++++- src/CrestApps.Core.Docs/docs/mcp/server.md | 29 +++ .../CrestAppsMcpHandlerBuilder.cs | 34 +++ .../AlgoliaDocumentationSourceFactory.cs | 56 +++++ .../DefaultDocumentationSourceCatalog.cs | 53 ++++ .../DefaultDocumentationSourceProvider.cs | 233 +++++++++++++----- .../Documentation/DocumentationSourceEntry.cs | 130 ++++++++++ .../DocumentationSourceEntryCatalogHandler.cs | 209 ++++++++++++++++ .../DocumentationSourceStrategies.cs | 27 ++ .../IDocumentationSourceCatalog.cs | 13 + .../IDocumentationSourceFactory.cs | 22 ++ .../IDocumentationSourceProvider.cs | 14 +- .../SearchIndexDocumentationSourceFactory.cs | 60 +++++ .../SitemapDocumentationSourceFactory.cs | 61 +++++ .../Functions/DocumentationSearchFunction.cs | 2 +- .../McpServerBuilderExtensions.cs | 39 +++ .../McpServerHandlerOptions.cs | 35 +++ .../ServiceCollectionExtensions.cs | 16 ++ src/Startup/CrestApps.Core.Mvc.Web/Program.cs | 4 + .../YesSqlServiceCollectionExtensions.cs | 1 + .../ServiceCollectionExtensions.cs | 33 +++ .../Mcp/DocumentationSourceEntryIndex.cs | 53 ++++ ...SourceEntryIndexSchemaBuilderExtensions.cs | 34 +++ .../ServiceCollectionExtensions.cs | 42 ++++ .../Core/Mcp/DocumentationSearchTests.cs | 55 ++++- .../Mcp/McpServerBuilderExtensionsTests.cs | 41 +++ 27 files changed, 1322 insertions(+), 71 deletions(-) create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSourceFactory.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceCatalog.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntry.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntryCatalogHandler.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceStrategies.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceCatalog.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceFactory.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSourceFactory.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSourceFactory.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/McpServerHandlerOptions.cs create mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndex.cs create mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndexSchemaBuilderExtensions.cs diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index 56b1317a..b90be1d6 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -31,6 +31,13 @@ page will be updated as changes land after 1.0.0. invoked, and multiple filters are AND-combined while values within a single call are OR-combined. This change is additive and backward compatible: the existing parameterless `WithCrestAppsHandlers()` still registers every capability and exposes all non-hidden tools +- adds an options-pattern overload `WithCrestAppsHandlers(IConfiguration, Action)` + and a new `McpServerHandlerOptions` type (`IncludeTools`, `IncludeSdkTools`, `IncludePrompts`, + `IncludeResources`) so the capability toggles can be enabled or disabled from configuration without + code. Each toggle is nullable, and **configuration wins over code**: a toggle set in configuration + overrides the value chosen in the code delegate, while an unset toggle keeps the code value. The + options are bound eagerly at registration time because they decide which handlers are registered and + therefore which capabilities the server advertises. This is additive and backward compatible ## Documentation search tool @@ -48,6 +55,21 @@ page will be updated as changes land after 1.0.0. `AddAlgoliaDocSearch(...)` forwards queries to the hosted Algolia DocSearch API used by many Docusaurus sites. Both also bind from configuration through the new `SearchIndexes` and `AlgoliaSources` lists on `DocumentationSearchOptions` +- adds store-backed documentation sources so operators can add, edit, and remove sources at runtime + (through an admin UI or directly in the database) in addition to declaring them in code. Documentation + sources persisted as `DocumentationSourceEntry` catalog entries are aggregated alongside options-defined + and custom sources. Enable a backend on the documentation search builder with `AddYesSqlStores()` or + `AddEntityCoreStores()`. The `Strategy` field selects how each entry is materialized (`sitemap`, + `search-index`, or `algolia`, defined by `DocumentationSourceStrategies`) through the matching + `IDocumentationSourceFactory`; new strategies can be added by registering an additional factory. Entries + are validated (strategy and its required fields, unique name) by the catalog handler and managed through + `INamedSourceCatalogManager`. The provider caches each materialized source and + rebuilds it only when its entry changes, so database edits are picked up on the next search +- reworks `IDocumentationSourceProvider.GetSources()` into the asynchronous + `GetSourcesAsync(IServiceProvider, CancellationToken)` so the tool can pass its request scope to the + singleton provider and resolve scoped services (the documentation source catalog and custom sources). + This interface was introduced in this same in-development cycle and has not shipped in a stable release, + so it is not a breaking change against 1.0.0 ## Fixes diff --git a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md index fa801ea5..17736563 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md +++ b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md @@ -269,13 +269,80 @@ Custom sources and configured sites are aggregated by `IDocumentationSourceProvi calls the tool without a `source` argument, every source is searched and results are merged by score; passing a `source` argument scopes the search to that single named source. +## Store-backed Sources + +Sites registered with `AddSite`, `AddSearchIndex`, and `AddAlgoliaDocSearch` are defined in code (or +bound from configuration). If you want operators to add, edit, and remove documentation sources at +runtime — through an admin UI or directly in the database — persist them in a store instead. + +Add a store backend to the documentation search builder: + +```csharp +// YesSql +.AddDocumentationSearch(docs => docs + .AddYesSqlStores()) + +// Entity Framework Core +.AddDocumentationSearch(docs => docs + .AddEntityCoreStores()) +``` + +Store-backed sources and code/options-defined sources are aggregated together, so you can seed a few +sites in code and let operators manage the rest from the database. + +Each stored source is a `DocumentationSourceEntry`. The `Strategy` field (stored in `Source`) selects +how the entry is materialized and must match a registered `IDocumentationSourceFactory.Strategy`. The +built-in strategies are defined by `DocumentationSourceStrategies`: + +| Strategy (`DocumentationSourceStrategies`) | Value | Required fields | +| --- | --- | --- | +| `Sitemap` | `sitemap` | `BaseUrl` (optional `SitemapUrl`, `MaxResults`, `MaxPages`) | +| `SearchIndex` | `search-index` | `BaseUrl` (optional `IndexUrl`, `MaxResults`) | +| `Algolia` | `algolia` | `ApplicationId`, `ApiKey`, `IndexName` (optional `MaxResults`) | + +Create and manage entries through the named-source catalog manager, which validates the strategy and +its required fields and enforces a unique name: + +```csharp +public sealed class DocumentationSourceService +{ + private readonly INamedSourceCatalogManager _manager; + + public DocumentationSourceService(INamedSourceCatalogManager manager) + { + _manager = manager; + } + + public async Task AddSitemapAsync(string name, string baseUrl, CancellationToken cancellationToken) + { + var entry = await _manager.NewAsync(name, DocumentationSourceStrategies.Sitemap, cancellationToken: cancellationToken); + entry.BaseUrl = baseUrl; + + var validation = await _manager.ValidateAsync(entry, cancellationToken); + + if (validation.Succeeded) + { + await _manager.CreateAsync(entry, cancellationToken); + } + } +} +``` + +The provider rebuilds a stored source only when its entry changes (tracked by the entry's modified +timestamp), so an edit in the database is picked up on the next search without restarting the host. + +To register a new strategy that can be stored in the catalog, implement `IDocumentationSourceFactory` +with a new `Strategy` identifier and register it as an `IDocumentationSourceFactory`. + ## How It Works 1. `AddCoreAIDocumentationSearch(...)` registers the `search_documentation` tool, the - `DefaultDocumentationSourceProvider`, and a named `HttpClient` with standard resilience. -2. When invoked, the tool resolves `IDocumentationSourceProvider` to get all sources (custom sources - plus a `SitemapDocumentationSource`, `SearchIndexDocumentationSource`, or `AlgoliaDocumentationSource` - per configured site, depending on the builder method used). + `DefaultDocumentationSourceProvider`, the strategy factories, the documentation source catalog and + manager, and a named `HttpClient` with standard resilience. +2. When invoked, the tool resolves `IDocumentationSourceProvider` to get all sources: custom sources, + the options-defined sites, and any entries persisted in the documentation source catalog — each + materialized through its `IDocumentationSourceFactory` (a `SitemapDocumentationSource`, + `SearchIndexDocumentationSource`, or `AlgoliaDocumentationSource`). 3. Each source is searched in parallel; a failing source is skipped so one broken site does not fail the whole search. 4. Results are merged, ordered by descending relevance, and returned with their titles and URLs so the diff --git a/src/CrestApps.Core.Docs/docs/mcp/server.md b/src/CrestApps.Core.Docs/docs/mcp/server.md index 57f7c901..870d3b00 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/server.md +++ b/src/CrestApps.Core.Docs/docs/mcp/server.md @@ -344,6 +344,35 @@ The `CrestAppsMcpHandlerBuilder` exposes: Tool filters are applied to **both** the list and call handlers, so a filtered-out tool can neither be discovered nor invoked. Multiple filters are combined with logical AND (a tool must satisfy every filter); values passed within a single call are combined with logical OR. `.Hidden()` tools are always excluded regardless of filters. +### Toggling capabilities from configuration + +The capability toggles above can also be driven from configuration, so an operator can enable or disable tools, SDK tools, prompts, and resources without changing code. Pass an `IConfiguration` section to `WithCrestAppsHandlers` and it binds `McpServerHandlerOptions`: + +```csharp +_ = builder.Services.AddMcpServer() + .WithHttpTransport() + .WithCrestAppsHandlers( + builder.Configuration.GetSection("Mcp:Server:Handlers"), + handlers => handlers.WithToolsInCategory("knowledgebase")); +``` + +```json +{ + "Mcp": { + "Server": { + "Handlers": { + "IncludeTools": true, + "IncludeSdkTools": false, + "IncludePrompts": true, + "IncludeResources": true + } + } + } +} +``` + +`McpServerHandlerOptions` exposes nullable toggles: `IncludeTools`, `IncludeSdkTools`, `IncludePrompts`, and `IncludeResources`. **Configuration wins over code** — any toggle explicitly set in configuration overrides the value chosen in the code delegate, while a toggle left unset (`null`) keeps the code value. Because these toggles decide whether the handlers are registered (and therefore which capabilities the server advertises), they are bound eagerly at registration time. + ## Server Metadata ### IMcpServerMetadataProvider diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs b/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs index c6c0394e..9b634e35 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs @@ -174,4 +174,38 @@ private void AddToolFilter(Func filter) { (_toolFilters ??= []).Add(filter); } + + /// + /// Overlays the supplied configuration-bound options onto this builder. Only options with an + /// explicit (non-) value are applied, and they override any value already + /// set in code so configuration wins over code for the capability toggles. + /// + /// The configuration-bound options to overlay. + internal void ApplyOptions(McpServerHandlerOptions options) + { + if (options is null) + { + return; + } + + if (options.IncludeTools.HasValue) + { + IncludeTools = options.IncludeTools.Value; + } + + if (options.IncludeSdkTools.HasValue) + { + IncludeSdkTools = options.IncludeSdkTools.Value; + } + + if (options.IncludePrompts.HasValue) + { + IncludePrompts = options.IncludePrompts.Value; + } + + if (options.IncludeResources.HasValue) + { + IncludeResources = options.IncludeResources.Value; + } + } } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSourceFactory.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSourceFactory.cs new file mode 100644 index 00000000..2e106cd8 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSourceFactory.cs @@ -0,0 +1,56 @@ +using System.Net.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// An that materializes a stored entry into an +/// . +/// +public sealed class AlgoliaDocumentationSourceFactory : IDocumentationSourceFactory +{ + private readonly DocumentationSearchOptions _options; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILoggerFactory _loggerFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The documentation search options. + /// The HTTP client factory. + /// The logger factory. + public AlgoliaDocumentationSourceFactory( + IOptions options, + IHttpClientFactory httpClientFactory, + ILoggerFactory loggerFactory) + { + _options = options.Value; + _httpClientFactory = httpClientFactory; + _loggerFactory = loggerFactory; + } + + /// + public string Strategy => DocumentationSourceStrategies.Algolia; + + /// + public IDocumentationSource Create(DocumentationSourceEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + var site = new AlgoliaDocSearchSite + { + Name = entry.Name, + ApplicationId = entry.ApplicationId, + ApiKey = entry.ApiKey, + IndexName = entry.IndexName, + MaxResults = entry.MaxResults, + }; + + return new AlgoliaDocumentationSource( + site, + _options, + _httpClientFactory, + _loggerFactory.CreateLogger()); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceCatalog.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceCatalog.cs new file mode 100644 index 00000000..339e73b3 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceCatalog.cs @@ -0,0 +1,53 @@ +using CrestApps.Core.Models; +using CrestApps.Core.Services; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// The default multi-source documentation source catalog. Aggregates entries from all registered +/// implementations (for example YesSql or EntityCore stores), +/// deduplicating by name so database-defined sources can be managed alongside custom catalog sources. +/// +public sealed class DefaultDocumentationSourceCatalog : MultiSourceNamedSourceCatalog, IDocumentationSourceCatalog +{ + /// + /// Initializes a new instance of the class. + /// + /// The registered catalog sources. + public DefaultDocumentationSourceCatalog(IEnumerable> sources) + : base(sources) + { + } + + /// + protected override string GetItemId(DocumentationSourceEntry entry) => entry.ItemId; + + /// + protected override IEnumerable ApplyFilters(QueryContext context, IEnumerable entries) + { + if (context is null) + { + return entries; + } + + if (!string.IsNullOrEmpty(context.Source)) + { + entries = entries.Where(entry => string.Equals(entry.Source, context.Source, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrEmpty(context.Name)) + { + entries = entries.Where(entry => entry.Name is not null && entry.Name.Contains(context.Name, StringComparison.OrdinalIgnoreCase)); + } + + if (context.Sorted) + { + entries = entries.OrderBy(static entry => entry.DisplayText ?? entry.Name, StringComparer.OrdinalIgnoreCase); + } + + return entries; + } + + /// + protected override string GetSortKey(DocumentationSourceEntry entry) => entry.DisplayText ?? entry.Name; +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs index 576b1409..5b601a3e 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs @@ -1,107 +1,224 @@ using System.Collections.Concurrent; -using System.Net.Http; -using Microsoft.Extensions.Logging; +using System.Globalization; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Mcp.Documentation; /// -/// Default that combines documentation sources registered -/// in code with built-in crawler sources materialized from . -/// Crawler sources are created once per configured site and reused so their in-memory corpus is cached. +/// Default that combines documentation sources defined in +/// options (through the documentation search builder), documentation sources persisted in a catalog +/// (for example a YesSql or EntityCore store), and custom +/// implementations registered in code. Materialized crawler sources are cached and only rebuilt when +/// their defining entry changes so their in-memory corpus is reused across searches. /// public sealed class DefaultDocumentationSourceProvider : IDocumentationSourceProvider { - private readonly IEnumerable _customSources; + private const string OptionsSignature = "options"; + private readonly IOptions _options; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILoggerFactory _loggerFactory; - private readonly TimeProvider _timeProvider; - private readonly ConcurrentDictionary _siteSources = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _searchIndexSources = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _algoliaSources = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _factories; + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); /// /// Initializes a new instance of the class. /// - /// The documentation sources registered in code. /// The documentation search options. - /// The HTTP client factory. - /// The logger factory. - /// The time provider. + /// The strategy factories used to materialize stored and options-defined entries. public DefaultDocumentationSourceProvider( - IEnumerable customSources, IOptions options, - IHttpClientFactory httpClientFactory, - ILoggerFactory loggerFactory, - TimeProvider timeProvider) + IEnumerable factories) { - _customSources = customSources; _options = options; - _httpClientFactory = httpClientFactory; - _loggerFactory = loggerFactory; - _timeProvider = timeProvider; + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var factory in factories) + { + map[factory.Strategy] = factory; + } + + _factories = map; } /// - public IReadOnlyList GetSources() + public async ValueTask> GetSourcesAsync(IServiceProvider services, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(services); + + var sources = new List(); + + foreach (var source in services.GetServices()) + { + sources.Add(source); + } + + var descriptors = new List(); + + CollectOptionsDescriptors(descriptors); + + await CollectCatalogDescriptorsAsync(services, descriptors, cancellationToken); + + var liveKeys = new HashSet(StringComparer.Ordinal); + + foreach (var descriptor in descriptors) + { + liveKeys.Add(descriptor.Key); + + var cached = _cache.AddOrUpdate( + descriptor.Key, + _ => new CachedSource(descriptor.Signature, descriptor.Factory.Create(descriptor.Entry)), + (_, existing) => string.Equals(existing.Signature, descriptor.Signature, StringComparison.Ordinal) + ? existing + : new CachedSource(descriptor.Signature, descriptor.Factory.Create(descriptor.Entry))); + + sources.Add(cached.Source); + } + + foreach (var key in _cache.Keys) + { + if (!liveKeys.Contains(key)) + { + _cache.TryRemove(key, out _); + } + } + + return sources; + } + + private void CollectOptionsDescriptors(List descriptors) { var options = _options.Value; - var sources = new List(_customSources); - foreach (var site in options.Sites) + if (_factories.TryGetValue(DocumentationSourceStrategies.Sitemap, out var sitemapFactory)) { - if (string.IsNullOrWhiteSpace(site.Name) || string.IsNullOrWhiteSpace(site.BaseUrl)) + foreach (var site in options.Sites) { - continue; + if (string.IsNullOrWhiteSpace(site.Name) || string.IsNullOrWhiteSpace(site.BaseUrl)) + { + continue; + } + + var entry = new DocumentationSourceEntry + { + Name = site.Name, + Source = DocumentationSourceStrategies.Sitemap, + BaseUrl = site.BaseUrl, + SitemapUrl = site.SitemapUrl, + MaxResults = site.MaxResults, + MaxPages = site.MaxPages, + }; + + descriptors.Add(new MaterializedDescriptor($"options:sitemap:{site.Name}", OptionsSignature, sitemapFactory, entry)); } + } - var source = _siteSources.GetOrAdd(site.Name, _ => new SitemapDocumentationSource( - site, - options, - _httpClientFactory, - _timeProvider, - _loggerFactory.CreateLogger())); + if (_factories.TryGetValue(DocumentationSourceStrategies.SearchIndex, out var searchIndexFactory)) + { + foreach (var site in options.SearchIndexes) + { + if (string.IsNullOrWhiteSpace(site.Name) || string.IsNullOrWhiteSpace(site.BaseUrl)) + { + continue; + } - sources.Add(source); + var entry = new DocumentationSourceEntry + { + Name = site.Name, + Source = DocumentationSourceStrategies.SearchIndex, + BaseUrl = site.BaseUrl, + IndexUrl = site.IndexUrl, + MaxResults = site.MaxResults, + }; + + descriptors.Add(new MaterializedDescriptor($"options:search-index:{site.Name}", OptionsSignature, searchIndexFactory, entry)); + } } - foreach (var site in options.SearchIndexes) + if (_factories.TryGetValue(DocumentationSourceStrategies.Algolia, out var algoliaFactory)) { - if (string.IsNullOrWhiteSpace(site.Name) || string.IsNullOrWhiteSpace(site.BaseUrl)) + foreach (var site in options.AlgoliaSources) { - continue; + if (string.IsNullOrWhiteSpace(site.Name) + || string.IsNullOrWhiteSpace(site.ApplicationId) + || string.IsNullOrWhiteSpace(site.ApiKey) + || string.IsNullOrWhiteSpace(site.IndexName)) + { + continue; + } + + var entry = new DocumentationSourceEntry + { + Name = site.Name, + Source = DocumentationSourceStrategies.Algolia, + ApplicationId = site.ApplicationId, + ApiKey = site.ApiKey, + IndexName = site.IndexName, + MaxResults = site.MaxResults, + }; + + descriptors.Add(new MaterializedDescriptor($"options:algolia:{site.Name}", OptionsSignature, algoliaFactory, entry)); } + } + } - var source = _searchIndexSources.GetOrAdd(site.Name, _ => new SearchIndexDocumentationSource( - site, - options, - _httpClientFactory, - _timeProvider, - _loggerFactory.CreateLogger())); + private async Task CollectCatalogDescriptorsAsync(IServiceProvider services, List descriptors, CancellationToken cancellationToken) + { + var catalog = services.GetService(); - sources.Add(source); + if (catalog is null) + { + return; } - foreach (var site in options.AlgoliaSources) + var entries = await catalog.GetAllAsync(cancellationToken); + + foreach (var entry in entries) { - if (string.IsNullOrWhiteSpace(site.Name) - || string.IsNullOrWhiteSpace(site.ApplicationId) - || string.IsNullOrWhiteSpace(site.ApiKey) - || string.IsNullOrWhiteSpace(site.IndexName)) + if (string.IsNullOrWhiteSpace(entry.Source) || !_factories.TryGetValue(entry.Source, out var factory)) { continue; } - var source = _algoliaSources.GetOrAdd(site.Name, _ => new AlgoliaDocumentationSource( - site, - options, - _httpClientFactory, - _loggerFactory.CreateLogger())); + var signature = (entry.ModifiedUtc ?? entry.CreatedUtc).Ticks.ToString(CultureInfo.InvariantCulture); - sources.Add(source); + descriptors.Add(new MaterializedDescriptor($"catalog:{entry.ItemId}", signature, factory, entry)); } + } - return sources; + private sealed class MaterializedDescriptor + { + public MaterializedDescriptor( + string key, + string signature, + IDocumentationSourceFactory factory, + DocumentationSourceEntry entry) + { + Key = key; + Signature = signature; + Factory = factory; + Entry = entry; + } + + public string Key { get; } + + public string Signature { get; } + + public IDocumentationSourceFactory Factory { get; } + + public DocumentationSourceEntry Entry { get; } + } + + private sealed class CachedSource + { + public CachedSource(string signature, IDocumentationSource source) + { + Signature = signature; + Source = source; + } + + public string Signature { get; } + + public IDocumentationSource Source { get; } } } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntry.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntry.cs new file mode 100644 index 00000000..ff4087f8 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntry.cs @@ -0,0 +1,130 @@ +using System.Text.Json.Serialization; +using CrestApps.Core.Models; +using CrestApps.Core.Services; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// A catalog entry that describes a documentation search source stored in a catalog (for example a +/// YesSql or EntityCore store) so sources can be created and managed through a UI or database in +/// addition to being registered in code. The value carries the +/// search strategy (see ), and the remaining properties hold +/// the union of settings used by the built-in strategies. +/// +public sealed class DocumentationSourceEntry : SourceCatalogEntry, INameAwareModel, IModifiedUtcAwareModel, ICloneable +{ + /// + /// Gets or sets the unique logical name of the source. A caller can scope a search to this name. + /// + public string Name { get; set; } + + /// + /// Gets or sets the search strategy identifier. This is an alias for + /// and must match a registered + /// (see ). + /// + [JsonIgnore] + public string Strategy + { + get => Source; + set => Source = value; + } + + /// + /// Gets or sets the human-readable display name of the source. + /// + public string DisplayText { get; set; } + + /// + /// Gets or sets the base URL of the documentation site. Used by the sitemap and search-index + /// strategies to resolve the sitemap, index, and result URLs. + /// + public string BaseUrl { get; set; } + + /// + /// Gets or sets an explicit sitemap URL for the sitemap strategy. When not set, the sitemap + /// strategy resolves it from . + /// + public string SitemapUrl { get; set; } + + /// + /// Gets or sets an explicit search index URL for the search-index strategy. When not set, the + /// search-index strategy resolves it from . + /// + public string IndexUrl { get; set; } + + /// + /// Gets or sets the Algolia application identifier for the Algolia strategy. + /// + public string ApplicationId { get; set; } + + /// + /// Gets or sets the Algolia search-only API key for the Algolia strategy. + /// + public string ApiKey { get; set; } + + /// + /// Gets or sets the Algolia index name for the Algolia strategy. + /// + public string IndexName { get; set; } + + /// + /// Gets or sets the maximum number of results this source contributes to a search. When not set, + /// the global value is used. + /// + public int? MaxResults { get; set; } + + /// + /// Gets or sets the maximum number of pages the sitemap strategy indexes. When not set, the global + /// value is used. + /// + public int? MaxPages { get; set; } + + /// + /// Gets or sets the UTC timestamp when this entry was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC timestamp when this entry was last modified. + /// + public DateTime? ModifiedUtc { get; set; } + + /// + /// Gets or sets the identifier of the user who created this entry. + /// + public string Author { get; set; } + + /// + /// Gets or sets the owner identifier associated with this entry. + /// + public string OwnerId { get; set; } + + /// + /// Creates a deep copy of this entry. + /// + /// The cloned entry. + public DocumentationSourceEntry Clone() + { + return new DocumentationSourceEntry + { + ItemId = ItemId, + Source = Source, + Name = Name, + DisplayText = DisplayText, + BaseUrl = BaseUrl, + SitemapUrl = SitemapUrl, + IndexUrl = IndexUrl, + ApplicationId = ApplicationId, + ApiKey = ApiKey, + IndexName = IndexName, + MaxResults = MaxResults, + MaxPages = MaxPages, + CreatedUtc = CreatedUtc, + ModifiedUtc = ModifiedUtc, + Author = Author, + OwnerId = OwnerId, + Properties = Properties.Clone(), + }; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntryCatalogHandler.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntryCatalogHandler.cs new file mode 100644 index 00000000..1a0b795e --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntryCatalogHandler.cs @@ -0,0 +1,209 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using System.Text.Json.Nodes; +using CrestApps.Core.Handlers; +using CrestApps.Core.Models; +using CrestApps.Core.Support; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// The authoritative catalog handler for . It populates entries +/// from JSON, sets create-time defaults, and validates that the strategy and its required fields are +/// present so store-backed sources can be created and edited safely through a UI or database. +/// +internal sealed class DocumentationSourceEntryCatalogHandler : CatalogEntryHandlerBase +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TimeProvider _timeProvider; + private readonly IDocumentationSourceCatalog _catalog; + private readonly HashSet _strategies; + + private readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP context accessor. + /// The time provider. + /// The documentation source catalog used to enforce unique names. + /// The registered strategy factories used to validate the strategy. + /// The string localizer. + public DocumentationSourceEntryCatalogHandler( + IHttpContextAccessor httpContextAccessor, + TimeProvider timeProvider, + IDocumentationSourceCatalog catalog, + IEnumerable factories, + IStringLocalizer stringLocalizer) + { + _httpContextAccessor = httpContextAccessor; + _timeProvider = timeProvider; + _catalog = catalog; + _strategies = new HashSet(factories.Select(factory => factory.Strategy), StringComparer.OrdinalIgnoreCase); + S = stringLocalizer; + } + + /// + public override Task InitializingAsync(InitializingContext context, CancellationToken cancellationToken = default) + => PopulateAsync(context.Model, context.Data, true); + + /// + public override async Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) + { + await PopulateAsync(context.Model, context.Data, false); + + context.Model.ModifiedUtc = _timeProvider.GetUtcNow().UtcDateTime; + } + + /// + public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) + { + EnsureCreatedDefaults(context.Model); + + return Task.CompletedTask; + } + + /// + public override Task CreatingAsync(CreatingContext context, CancellationToken cancellationToken = default) + { + EnsureCreatedDefaults(context.Model); + + return Task.CompletedTask; + } + + /// + public override async Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) + { + var model = context.Model; + + if (string.IsNullOrWhiteSpace(model.Name)) + { + context.Result.Fail(new ValidationResult(S["Name is required."], [nameof(DocumentationSourceEntry.Name)])); + } + + if (string.IsNullOrWhiteSpace(model.Source)) + { + context.Result.Fail(new ValidationResult(S["Strategy is required."], [nameof(DocumentationSourceEntry.Strategy)])); + } + else if (!_strategies.Contains(model.Source)) + { + context.Result.Fail(new ValidationResult(S["Unknown documentation search strategy '{0}'.", model.Source], [nameof(DocumentationSourceEntry.Strategy)])); + } + else + { + ValidateStrategyFields(context); + } + + await ValidateUniqueNameAsync(context, cancellationToken); + } + + private void ValidateStrategyFields(ValidatingContext context) + { + var model = context.Model; + + if (string.Equals(model.Source, DocumentationSourceStrategies.Algolia, StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(model.ApplicationId)) + { + context.Result.Fail(new ValidationResult(S["Application Id is required for the Algolia strategy."], [nameof(DocumentationSourceEntry.ApplicationId)])); + } + + if (string.IsNullOrWhiteSpace(model.ApiKey)) + { + context.Result.Fail(new ValidationResult(S["API Key is required for the Algolia strategy."], [nameof(DocumentationSourceEntry.ApiKey)])); + } + + if (string.IsNullOrWhiteSpace(model.IndexName)) + { + context.Result.Fail(new ValidationResult(S["Index Name is required for the Algolia strategy."], [nameof(DocumentationSourceEntry.IndexName)])); + } + + return; + } + + if (string.IsNullOrWhiteSpace(model.BaseUrl)) + { + context.Result.Fail(new ValidationResult(S["Base URL is required for the '{0}' strategy.", model.Source], [nameof(DocumentationSourceEntry.BaseUrl)])); + } + } + + private void EnsureCreatedDefaults(DocumentationSourceEntry entry) + { + if (entry.CreatedUtc == default) + { + entry.CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + } + + var user = _httpContextAccessor.HttpContext?.User; + + if (user is null) + { + return; + } + + entry.OwnerId ??= user.FindFirstValue(ClaimTypes.NameIdentifier); + entry.Author ??= user.Identity?.Name; + } + + private async Task ValidateUniqueNameAsync(ValidatingContext context, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(context.Model.Name)) + { + return; + } + + var existing = await _catalog.FindByNameAsync(context.Model.Name, cancellationToken); + + if (existing is not null && !string.Equals(existing.ItemId, context.Model.ItemId, StringComparison.Ordinal)) + { + context.Result.Fail(new ValidationResult(S["A documentation source with this name already exists. The name must be unique."], [nameof(DocumentationSourceEntry.Name)])); + } + } + + private static Task PopulateAsync(DocumentationSourceEntry entry, JsonNode data, bool isNew) + { + if (data is not JsonObject json) + { + return Task.CompletedTask; + } + + if (isNew) + { + json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.Name), value => entry.Name = value); + } + + if (!json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.Strategy), value => entry.Source = value)) + { + json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.Source), value => entry.Source = value); + } + + json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.DisplayText), value => entry.DisplayText = value); + json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.BaseUrl), value => entry.BaseUrl = value); + json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.SitemapUrl), value => entry.SitemapUrl = value); + json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.IndexUrl), value => entry.IndexUrl = value); + json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.ApplicationId), value => entry.ApplicationId = value); + json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.ApiKey), value => entry.ApiKey = value); + json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.IndexName), value => entry.IndexName = value); + json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.OwnerId), value => entry.OwnerId = value); + json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.Author), value => entry.Author = value); + + if (json.TryGetNullableInt32Value(nameof(DocumentationSourceEntry.MaxResults), out var maxResults)) + { + entry.MaxResults = maxResults; + } + + if (json.TryGetNullableInt32Value(nameof(DocumentationSourceEntry.MaxPages), out var maxPages)) + { + entry.MaxPages = maxPages; + } + + if (json.TryGetDateTimeValue(nameof(DocumentationSourceEntry.CreatedUtc), out var createdUtc)) + { + entry.CreatedUtc = createdUtc; + } + + return Task.CompletedTask; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceStrategies.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceStrategies.cs new file mode 100644 index 00000000..3bc571dd --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceStrategies.cs @@ -0,0 +1,27 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// The well-known documentation search strategy identifiers. A strategy determines how a stored +/// is materialized into a runtime . +/// The value is stored in so the catalog can carry +/// heterogeneous strategies in a single store. Custom strategies can be added by registering an +/// with a new strategy identifier. +/// +public static class DocumentationSourceStrategies +{ + /// + /// The strategy that crawls a site's sitemap.xml and ranks pages locally. + /// + public const string Sitemap = "sitemap"; + + /// + /// The strategy that downloads a prebuilt JSON search index (for example a MkDocs Material + /// search_index.json) and ranks its entries locally. + /// + public const string SearchIndex = "search-index"; + + /// + /// The strategy that forwards queries to the hosted Algolia DocSearch query API. + /// + public const string Algolia = "algolia"; +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceCatalog.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceCatalog.cs new file mode 100644 index 00000000..2ff412e0 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceCatalog.cs @@ -0,0 +1,13 @@ +using CrestApps.Core.Services; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// A catalog of items. Implementations aggregate documentation +/// source entries from all registered catalog sources (for example a YesSql or EntityCore store) so the +/// documentation search tool can materialize sources defined in a database or through a UI in addition +/// to those registered in code. +/// +public interface IDocumentationSourceCatalog : INamedSourceCatalog +{ +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceFactory.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceFactory.cs new file mode 100644 index 00000000..b0c1f343 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceFactory.cs @@ -0,0 +1,22 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Materializes a stored into a runtime +/// for a specific search strategy. Register an implementation to add +/// support for a new strategy that can be stored in the documentation source catalog. +/// +public interface IDocumentationSourceFactory +{ + /// + /// Gets the strategy identifier this factory handles. This is matched against + /// (see ). + /// + string Strategy { get; } + + /// + /// Creates a documentation source from the supplied entry. + /// + /// The stored source entry to materialize. + /// The runtime documentation source. + IDocumentationSource Create(DocumentationSourceEntry entry); +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceProvider.cs index 04489f8f..9dddac9e 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceProvider.cs @@ -1,15 +1,21 @@ namespace CrestApps.Core.AI.Mcp.Documentation; /// -/// Resolves the complete set of documentation sources available to the documentation search tool. -/// The default implementation aggregates sources registered in code with the built-in crawler -/// sources materialized from . +/// Resolves the complete set of documentation sources available to the documentation search tool. The +/// default implementation aggregates sources defined in options (through the documentation search +/// builder), sources stored in a catalog (for example a YesSql or EntityCore store), and custom +/// implementations registered in code. /// public interface IDocumentationSourceProvider { /// /// Gets all documentation sources that can be searched. /// + /// + /// The request service provider used to resolve scoped services such as the documentation source + /// catalog and custom sources. + /// + /// The cancellation token. /// The available documentation sources. - IReadOnlyList GetSources(); + ValueTask> GetSourcesAsync(IServiceProvider services, CancellationToken cancellationToken = default); } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSourceFactory.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSourceFactory.cs new file mode 100644 index 00000000..dbfd92a0 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSourceFactory.cs @@ -0,0 +1,60 @@ +using System.Net.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// An that materializes a stored entry into a +/// . +/// +public sealed class SearchIndexDocumentationSourceFactory : IDocumentationSourceFactory +{ + private readonly DocumentationSearchOptions _options; + private readonly IHttpClientFactory _httpClientFactory; + private readonly TimeProvider _timeProvider; + private readonly ILoggerFactory _loggerFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The documentation search options. + /// The HTTP client factory. + /// The time provider. + /// The logger factory. + public SearchIndexDocumentationSourceFactory( + IOptions options, + IHttpClientFactory httpClientFactory, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) + { + _options = options.Value; + _httpClientFactory = httpClientFactory; + _timeProvider = timeProvider; + _loggerFactory = loggerFactory; + } + + /// + public string Strategy => DocumentationSourceStrategies.SearchIndex; + + /// + public IDocumentationSource Create(DocumentationSourceEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + var site = new DocumentationSearchIndexSite + { + Name = entry.Name, + BaseUrl = entry.BaseUrl, + IndexUrl = entry.IndexUrl, + MaxResults = entry.MaxResults, + }; + + return new SearchIndexDocumentationSource( + site, + _options, + _httpClientFactory, + _timeProvider, + _loggerFactory.CreateLogger()); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSourceFactory.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSourceFactory.cs new file mode 100644 index 00000000..53d6d2f6 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSourceFactory.cs @@ -0,0 +1,61 @@ +using System.Net.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// An that materializes a stored entry into a +/// . +/// +public sealed class SitemapDocumentationSourceFactory : IDocumentationSourceFactory +{ + private readonly DocumentationSearchOptions _options; + private readonly IHttpClientFactory _httpClientFactory; + private readonly TimeProvider _timeProvider; + private readonly ILoggerFactory _loggerFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The documentation search options. + /// The HTTP client factory. + /// The time provider. + /// The logger factory. + public SitemapDocumentationSourceFactory( + IOptions options, + IHttpClientFactory httpClientFactory, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) + { + _options = options.Value; + _httpClientFactory = httpClientFactory; + _timeProvider = timeProvider; + _loggerFactory = loggerFactory; + } + + /// + public string Strategy => DocumentationSourceStrategies.Sitemap; + + /// + public IDocumentationSource Create(DocumentationSourceEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + var site = new DocumentationSite + { + Name = entry.Name, + BaseUrl = entry.BaseUrl, + SitemapUrl = entry.SitemapUrl, + MaxResults = entry.MaxResults, + MaxPages = entry.MaxPages, + }; + + return new SitemapDocumentationSource( + site, + _options, + _httpClientFactory, + _timeProvider, + _loggerFactory.CreateLogger()); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Functions/DocumentationSearchFunction.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/DocumentationSearchFunction.cs index abcdf5cd..f13c1f2e 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Functions/DocumentationSearchFunction.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/DocumentationSearchFunction.cs @@ -84,7 +84,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a } var provider = arguments.Services.GetRequiredService(); - var sources = provider.GetSources(); + var sources = await provider.GetSourcesAsync(arguments.Services, cancellationToken); arguments.TryGetFirstString("source", out var sourceName); diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs index 019b445c..eba05925 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs @@ -1,6 +1,7 @@ using CrestApps.Core.AI.Mcp.Services; using CrestApps.Core.AI.Tooling; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -38,12 +39,50 @@ public static IMcpServerBuilder WithCrestAppsHandlers(this IMcpServerBuilder bui public static IMcpServerBuilder WithCrestAppsHandlers( this IMcpServerBuilder builder, Action configure) + { + return builder.WithCrestAppsHandlers(configuration: null, configure); + } + + /// + /// Registers the CrestApps MCP server handlers, binding the capability toggles from the supplied + /// configuration section so a host can enable or disable capabilities without code. Configuration + /// wins over code: any capability toggle explicitly set in + /// overrides the value chosen in . + /// + /// The builder. + /// + /// The configuration section bound to . When + /// no configuration binding is applied. + /// + /// A delegate that configures the exposed capabilities and tool filters. + public static IMcpServerBuilder WithCrestAppsHandlers( + this IMcpServerBuilder builder, + IConfiguration configuration, + Action configure) { ArgumentNullException.ThrowIfNull(builder); var handlerBuilder = new CrestAppsMcpHandlerBuilder(); configure?.Invoke(handlerBuilder); + if (configuration is not null) + { + var options = new McpServerHandlerOptions(); + configuration.Bind(options); + handlerBuilder.ApplyOptions(options); + + builder.Services.Configure(configuration); + } + + return builder.WithCrestAppsHandlers(handlerBuilder); + } + + private static IMcpServerBuilder WithCrestAppsHandlers( + this IMcpServerBuilder builder, + CrestAppsMcpHandlerBuilder handlerBuilder) + { + ArgumentNullException.ThrowIfNull(builder); + if (handlerBuilder.IncludeTools) { var includeSdkTools = handlerBuilder.IncludeSdkTools; diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerHandlerOptions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerHandlerOptions.cs new file mode 100644 index 00000000..1d038156 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerHandlerOptions.cs @@ -0,0 +1,35 @@ +namespace CrestApps.Core.AI.Mcp; + +/// +/// Strongly-typed options that control which CrestApps MCP server capabilities are exposed. Bind this +/// type from configuration (for example an Mcp:Server:Handlers section) so a host can enable or +/// disable capabilities without code. Each toggle is nullable: a value means the +/// setting is not configured and the value chosen in code is used instead. +/// +public sealed class McpServerHandlerOptions +{ + /// + /// Gets or sets a value indicating whether the tool list and call handlers are registered. When + /// the value configured in code is used. + /// + public bool? IncludeTools { get; set; } + + /// + /// Gets or sets a value indicating whether SDK tool instances registered in the service provider + /// are merged into the exposed tool set. This only applies when resolves + /// to . When the value configured in code is used. + /// + public bool? IncludeSdkTools { get; set; } + + /// + /// Gets or sets a value indicating whether the prompt list and get handlers are registered. When + /// the value configured in code is used. + /// + public bool? IncludePrompts { get; set; } + + /// + /// Gets or sets a value indicating whether the resource list, template list, and read handlers are + /// registered. When the value configured in code is used. + /// + public bool? IncludeResources { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs index a132be2d..ba469610 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs @@ -244,6 +244,22 @@ public static IServiceCollection AddCoreAIDocumentationSearch( .AddStandardResilienceHandler(); services.TryAddSingleton(); + // Register the strategy factories used to materialize options-defined and store-backed entries. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + // Register the multi-source catalog so documentation sources can be persisted in a store and + // managed through a UI or database. The catalog is empty until a store backend adds a binding source. + services.TryAddScoped(); + services.TryAddScoped>(sp => sp.GetRequiredService()); + + services.TryAddScoped>(); + services.TryAddScoped>(sp => sp.GetRequiredService>()); + services.TryAddScoped>(sp => sp.GetRequiredService>()); + + services.TryAddEnumerable(ServiceDescriptor.Scoped, DocumentationSourceEntryCatalogHandler>()); + if (!services.Any(descriptor => descriptor.ServiceType == typeof(DocumentationSearchFunction))) { services.AddCoreAITool(DocumentationSearchFunction.TheName) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index 23f55562..89c4211f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -131,6 +131,10 @@ .AddYesSqlStores() .AddFtpResources() .AddSftpResources() + .AddDocumentationSearch(documentation => documentation + .AddYesSqlStores() + .AddSite("crestapps", "https://core.crestapps.com") + ) ) .AddSignalR(addStoreCommitterFilter: true) .AddA2AHost() diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs index c0d91061..d6976d08 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs @@ -147,6 +147,7 @@ public static async Task InitializeYesSqlSchemaAsync(this IServiceProvider servi await TryCreateTableAsync(() => schemaBuilder.CreateAIToolInstanceIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpPromptIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpResourceIndexSchemaAsync(storeOptions)); + await TryCreateTableAsync(() => schemaBuilder.CreateDocumentationSourceEntryIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIDeploymentIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIProfileTemplateIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIChatSessionIndexSchemaAsync(storeOptions)); diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs index 5050c89c..2f6f164d 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.DataSources; using CrestApps.Core.AI.Documents; +using CrestApps.Core.AI.Mcp.Documentation; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; @@ -204,6 +205,23 @@ public static IServiceCollection AddCoreAIMcpServerStoresEntityCore(this IServic return services; } + /// + /// Registers EntityCore-backed storage for the documentation search feature. This adds a writable + /// binding source for so documentation sources persisted in + /// the store are aggregated by the documentation source catalog alongside options-defined sources. + /// + /// The service collection. + public static IServiceCollection AddCoreAIMcpDocumentationSearchStoresEntityCore(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddScoped>(); + services.AddScoped>(sp => + new WritableCatalogBindingSource(sp.GetRequiredService>())); + + return services; + } + /// /// Registers EntityCore-backed stores for the AI chat sessions feature. /// This includes and . @@ -456,6 +474,21 @@ public static CrestAppsMcpServerBuilder AddEntityCoreStores(this CrestAppsMcpSer return builder; } + /// + /// Registers EntityCore-backed storage for the documentation search feature on the documentation + /// search builder, so documentation sources can be persisted and managed through a UI or database + /// in addition to being registered in code. + /// + /// The documentation search builder. + public static DocumentationSearchBuilder AddEntityCoreStores(this DocumentationSearchBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddCoreAIMcpDocumentationSearchStoresEntityCore(); + + return builder; + } + /// /// Registers EntityCore-backed stores for the chat interactions feature on the chat interactions builder. /// This includes a catalog for and . diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndex.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndex.cs new file mode 100644 index 00000000..833ec6aa --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndex.cs @@ -0,0 +1,53 @@ +using CrestApps.Core.AI.Mcp.Documentation; +using Microsoft.Extensions.Options; +using YesSql.Indexes; + +namespace CrestApps.Core.Data.YesSql.Indexes.Mcp; + +/// +/// YesSql map index for , storing the item identifier, unique +/// name, and strategy (source) to support efficient documentation source catalog queries. +/// +public sealed class DocumentationSourceEntryIndex : CatalogItemIndex, INameAwareIndex, ISourceAwareIndex +{ + /// + /// Gets or sets the unique logical name of the documentation source. + /// + public string Name { get; set; } + + /// + /// Gets or sets the search strategy identifier of the documentation source. + /// + public string Source { get; set; } +} + +/// +/// YesSql index provider that maps documents to +/// entries in the AI collection. +/// +public sealed class DocumentationSourceEntryIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + /// The options. + public DocumentationSourceEntryIndexProvider(IOptions options) + { + CollectionName = options.Value.AICollectionName; + } + + /// + /// Describes the index mapping. + /// + /// The context. + public override void Describe(DescribeContext context) + { + context.For() + .Map(entry => new DocumentationSourceEntryIndex + { + ItemId = entry.ItemId, + Name = entry.Name, + Source = entry.Source, + }); + } +} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndexSchemaBuilderExtensions.cs new file mode 100644 index 00000000..4ab3f352 --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndexSchemaBuilderExtensions.cs @@ -0,0 +1,34 @@ +using YesSql.Sql; + +namespace CrestApps.Core.Data.YesSql.Indexes.Mcp; + +/// +/// Schema builder extensions for the table. +/// +public static class DocumentationSourceEntryIndexSchemaBuilderExtensions +{ + /// + /// Creates the documentation source entry index schema. + /// + /// The schema builder. + /// The options. + public static async Task CreateDocumentationSourceEntryIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) + { + ArgumentNullException.ThrowIfNull(schemaBuilder); + ArgumentNullException.ThrowIfNull(options); + + await schemaBuilder.CreateMapIndexTableAsync(table => table + .Column(nameof(DocumentationSourceEntryIndex.ItemId), column => column.WithLength(26)) + .Column(nameof(DocumentationSourceEntryIndex.Name), column => column.WithLength(255)) + .Column(nameof(DocumentationSourceEntryIndex.Source), column => column.WithLength(255)), + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync( + table => table.CreateIndex("IDX_DocumentationSourceEntry_DocumentId", "DocumentId", nameof(DocumentationSourceEntryIndex.Name)), + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync( + table => table.CreateIndex("IDX_DocumentationSourceEntry_Source", "DocumentId", nameof(DocumentationSourceEntryIndex.Source)), + collection: options?.AICollectionName); + } +} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs index 6acdf82b..58ce0d0c 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.DataSources; using CrestApps.Core.AI.Documents; +using CrestApps.Core.AI.Mcp.Documentation; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; @@ -179,6 +180,21 @@ public static CrestAppsMcpServerBuilder AddYesSqlStores(this CrestAppsMcpServerB return builder; } + /// + /// Registers YesSql-backed storage for the documentation search feature on the documentation search + /// builder, so documentation sources can be persisted and managed through a UI or database in + /// addition to being registered in code. + /// + /// The documentation search builder. + public static DocumentationSearchBuilder AddYesSqlStores(this DocumentationSearchBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddCoreAIMcpDocumentationSearchStoresYesSql(); + + return builder; + } + /// /// Registers YesSql-backed stores for the chat interactions feature on the chat interactions builder. /// This includes a catalog for and . @@ -381,6 +397,32 @@ public static IServiceCollection AddCoreAIMcpServerStoresYesSql(this IServiceCol return services; } + /// + /// Registers YesSql-backed storage for the documentation search feature. This adds a writable + /// binding source for so documentation sources persisted in + /// the store are aggregated by the documentation source catalog alongside options-defined sources. + /// + /// The service collection. + public static IServiceCollection AddCoreAIMcpDocumentationSearchStoresYesSql(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddScoped(sp => + { + var session = sp.GetRequiredService(); + var options = sp.GetRequiredService>().Value; + + return new NamedSourceDocumentCatalog(session, options.AICollectionName); + }); + + services.AddScoped>(sp => + new WritableCatalogBindingSource(sp.GetRequiredService>())); + + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + return services; + } + /// /// Registers all YesSql-backed stores for the AI chat sessions feature. /// This is a convenience method that registers the core chat session stores diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs index fd7ce496..35b6323d 100644 --- a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs @@ -2,6 +2,7 @@ using CrestApps.Core.AI.Mcp.Documentation; using CrestApps.Core.AI.Mcp.Functions; using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Services; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -109,7 +110,7 @@ public void AddAlgoliaDocSearch_PopulatesOptions() /// crawler sources materialized from the configured sites. /// [Fact] - public void SourceProvider_AggregatesCustomAndConfiguredSites() + public async Task SourceProvider_AggregatesCustomAndConfiguredSites() { var services = new ServiceCollection(); services.AddLogging(); @@ -121,7 +122,8 @@ public void SourceProvider_AggregatesCustomAndConfiguredSites() using var provider = services.BuildServiceProvider(); - var sources = provider.GetRequiredService().GetSources(); + var sources = await provider.GetRequiredService() + .GetSourcesAsync(provider, TestContext.Current.CancellationToken); Assert.Contains(sources, source => source.Name == "custom-1"); Assert.Contains(sources, source => source.Name == "site-1"); @@ -129,6 +131,34 @@ public void SourceProvider_AggregatesCustomAndConfiguredSites() Assert.Contains(sources, source => source.Name == "algolia-1"); } + /// + /// Verifies that the source provider materializes documentation sources stored in the catalog (for + /// example a database-backed store) through the registered strategy factories. + /// + [Fact] + public async Task SourceProvider_MaterializesCatalogEntries() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCoreAIDocumentationSearch(); + services.AddScoped>(_ => new FakeCatalogSource( + new DocumentationSourceEntry + { + ItemId = "01HZZZDBSOURCE0000000000001", + Name = "stored-site", + Source = DocumentationSourceStrategies.Sitemap, + BaseUrl = "https://stored.example.com", + })); + + using var root = services.BuildServiceProvider(); + using var scope = root.CreateScope(); + + var sources = await scope.ServiceProvider.GetRequiredService() + .GetSourcesAsync(scope.ServiceProvider, TestContext.Current.CancellationToken); + + Assert.Contains(sources, source => source.Name == "stored-site"); + } + /// /// Verifies that the tool returns a helpful message when no documentation sources are configured. /// @@ -258,9 +288,26 @@ public StubSourceProvider(IReadOnlyList sources) _sources = sources; } - public IReadOnlyList GetSources() + public ValueTask> GetSourcesAsync(IServiceProvider services, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(_sources); + } + } + + private sealed class FakeCatalogSource : INamedSourceCatalogSource + { + private readonly IReadOnlyCollection _entries; + + public FakeCatalogSource(params DocumentationSourceEntry[] entries) + { + _entries = entries; + } + + public int Order => 0; + + public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries, CancellationToken cancellationToken = default) { - return _sources; + return ValueTask.FromResult(_entries); } } diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs index cc8aa99a..ab3c0fd7 100644 --- a/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs @@ -15,6 +15,47 @@ namespace CrestApps.Core.Tests.Core.Mcp; public sealed class McpServerBuilderExtensionsTests { + /// + /// Verifies that a capability toggle set through options overrides the value chosen in code, so a + /// host can enable or disable capabilities from configuration without changing code. + /// + [Fact] + public void ApplyOptions_ConfigurationWinsOverCode() + { + var handlerBuilder = new CrestAppsMcpHandlerBuilder(); + handlerBuilder.WithoutTools(); + handlerBuilder.WithoutSdkTools(); + + handlerBuilder.ApplyOptions(new McpServerHandlerOptions + { + IncludeTools = true, + IncludeSdkTools = true, + IncludePrompts = false, + }); + + Assert.True(handlerBuilder.IncludeTools); + Assert.True(handlerBuilder.IncludeSdkTools); + Assert.False(handlerBuilder.IncludePrompts); + } + + /// + /// Verifies that a capability toggle leaves the value chosen in code intact, + /// so unset configuration does not override explicit code choices. + /// + [Fact] + public void ApplyOptions_NullTogglesPreserveCodeValues() + { + var handlerBuilder = new CrestAppsMcpHandlerBuilder(); + handlerBuilder.WithoutResources(); + + handlerBuilder.ApplyOptions(new McpServerHandlerOptions()); + + Assert.True(handlerBuilder.IncludeTools); + Assert.True(handlerBuilder.IncludeSdkTools); + Assert.True(handlerBuilder.IncludePrompts); + Assert.False(handlerBuilder.IncludeResources); + } + /// /// Verifies that visible local tools retain registration order, precede SDK tools, and hidden tools are omitted. /// From a68c7583cfa327bdb679991119bc553355371eea Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 10 Aug 2026 02:00:12 +0300 Subject: [PATCH 08/13] Redesign MCP documentation search onto AI tool instances Rebuild the MCP documentation-search feature on the existing AI tool instances foundation and make MCP tool exposure an opt-in allow-list. Framework - Replace the bespoke documentation-source catalog/store/index/provider stack and the single search_documentation function with per-site tool instance sources (sitemap crawl, prebuilt search index, Algolia DocSearch), each producing a DocumentationSearchToolFunction bound to one site. Register with AddDocumentationSearchSources(). - Rework MCP tool exposure into an opt-in allow-list on the site-settings McpServerOptions: nothing is exposed by default; only tools/instances named in Tools, or all non-hidden ones when ExposeAllTools is true. - Read McpServerOptions via IOptionsMonitor so allow-list changes apply at runtime without a restart. - Resolve a listed code tool by its published AIFunction.Name even when it differs from the DI registration key, so a listed tool is callable. - Resolve IOptions in the tool sources so DI-configured caps/cache settings apply. - Rethrow cancellation from the documentation search function instead of reporting it as an error; return a stable, non-leaking failure message. Sample hosts - Add source-specific editors (fields, validation, persistence, and edit-time load) for the three documentation sources to the MVC and Blazor tool instance forms so operators can configure a site end-to-end. - Add an exposed-tools editor to the MVC and Blazor MCP server settings. Docs - Rewrite the MCP server, documentation-search, and 1.1.0 changelog pages for the allow-list + tool-instance model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/changelog/1.1.0.md | 88 ++- .../docs/mcp/documentation-search.md | 375 ++++-------- src/CrestApps.Core.Docs/docs/mcp/server.md | 74 +-- .../CrestAppsMcpHandlerBuilder.cs | 161 +---- .../AlgoliaDocumentationSourceFactory.cs | 56 -- .../AlgoliaDocumentationToolSettings.cs | 31 + .../AlgoliaDocumentationToolSource.cs | 56 ++ .../DefaultDocumentationSourceCatalog.cs | 53 -- .../DefaultDocumentationSourceMaterializer.cs | 43 ++ .../DefaultDocumentationSourceProvider.cs | 224 ------- .../DocumentationSearchBuilder.cs | 147 ----- .../DocumentationSearchOptions.cs | 23 +- .../DocumentationSearchToolFunction.cs | 205 +++++++ .../Documentation/DocumentationSourceEntry.cs | 130 ---- .../DocumentationSourceEntryCatalogHandler.cs | 209 ------- .../DocumentationSourceStrategies.cs | 27 - .../DocumentationToolConstants.cs | 31 + ...ToolInstanceServiceCollectionExtensions.cs | 129 ++++ .../IDocumentationSourceCatalog.cs | 13 - .../IDocumentationSourceFactory.cs | 22 - .../IDocumentationSourceMaterializer.cs | 19 + .../IDocumentationSourceProvider.cs | 21 - .../SearchIndexDocumentationSourceFactory.cs | 60 -- .../SearchIndexDocumentationToolSettings.cs | 28 + .../SearchIndexDocumentationToolSource.cs | 57 ++ .../SitemapDocumentationSourceFactory.cs | 61 -- .../SitemapDocumentationToolSettings.cs | 33 ++ .../SitemapDocumentationToolSource.cs | 57 ++ .../Functions/DocumentationSearchFunction.cs | 178 ------ .../McpServerBuilderExtensions.cs | 291 ++++++--- .../McpServerHandlerOptions.cs | 35 -- .../Models/McpServerOptions.cs | 16 + .../ServiceCollectionExtensions.cs | 69 --- .../Pages/Admin/Settings/Index.razor | 26 + .../Pages/Tooling/ToolInstances/Create.razor | 174 ++++++ .../Pages/Tooling/ToolInstances/Edit.razor | 199 +++++++ .../CrestApps.Core.Blazor.Web/Program.cs | 2 + .../ViewModels/AIToolInstanceViewModel.cs | 58 ++ .../ViewModels/SettingsViewModel.cs | 4 + .../Admin/Controllers/SettingsController.cs | 11 + .../Admin/ViewModels/SettingsViewModel.cs | 4 + .../Areas/Admin/Views/Settings/Index.cshtml | 14 + .../Controllers/AIToolInstanceController.cs | 131 ++++ .../ViewModels/AIToolInstanceViewModel.cs | 58 ++ .../Tooling/Views/AIToolInstance/_Form.cshtml | 74 +++ src/Startup/CrestApps.Core.Mvc.Web/Program.cs | 6 +- .../YesSqlServiceCollectionExtensions.cs | 1 - .../ServiceCollectionExtensions.cs | 33 -- .../Mcp/DocumentationSourceEntryIndex.cs | 53 -- ...SourceEntryIndexSchemaBuilderExtensions.cs | 34 -- .../ServiceCollectionExtensions.cs | 42 -- .../Core/Mcp/DocumentationSearchTests.cs | 320 ++++------ .../Mcp/McpServerBuilderExtensionsTests.cs | 557 +++++++----------- 53 files changed, 2143 insertions(+), 2680 deletions(-) delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSourceFactory.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSettings.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSource.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceCatalog.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceMaterializer.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchBuilder.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchToolFunction.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntry.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntryCatalogHandler.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceStrategies.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolConstants.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolInstanceServiceCollectionExtensions.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceCatalog.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceFactory.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceMaterializer.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceProvider.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSourceFactory.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSettings.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSource.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSourceFactory.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSettings.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSource.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/Functions/DocumentationSearchFunction.cs delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/McpServerHandlerOptions.cs delete mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndex.cs delete mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndexSchemaBuilderExtensions.cs diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index b90be1d6..66f9cf6e 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -19,59 +19,53 @@ page will be updated as changes land after 1.0.0. - upgrades the framework's dependency baseline, including YesSql 6.0 (new `ISession.SaveAsync` signature), the Model Context Protocol 2.0 packages, the GitHub Copilot SDK 1.0.8 (new `PermissionsApi.SetAllowAllAsync` mode-based API), Anthropic 12.39.0, OllamaSharp 5.4.30, the .NET 10.0.10 runtime/extension packages, and the `Microsoft.Extensions.AI` 10.8.3 packages -## MCP server capability and tool selection +## MCP server capability and tool exposure - adds an optional configuration delegate to `WithCrestAppsHandlers(...)` so an MCP server host can - choose which capabilities to expose and which tools to list and invoke. The new - `CrestAppsMcpHandlerBuilder` provides `WithoutTools()`, `WithoutSdkTools()`, `WithoutPrompts()`, - `WithoutResources()`, and tool filters (`WithToolsInCategory`, `WithToolsForPurpose`, `WithToolNames`, - and `FilterTools`). This makes it possible to expose a read-only knowledgebase server (prompts and - resources only) or a server that only exposes a specific category/purpose/allow-list of tools. Tool - filters apply to both the list and call handlers, so a filtered-out tool can neither be discovered nor - invoked, and multiple filters are AND-combined while values within a single call are OR-combined. This - change is additive and backward compatible: the existing parameterless `WithCrestAppsHandlers()` still - registers every capability and exposes all non-hidden tools -- adds an options-pattern overload `WithCrestAppsHandlers(IConfiguration, Action)` - and a new `McpServerHandlerOptions` type (`IncludeTools`, `IncludeSdkTools`, `IncludePrompts`, - `IncludeResources`) so the capability toggles can be enabled or disabled from configuration without - code. Each toggle is nullable, and **configuration wins over code**: a toggle set in configuration - overrides the value chosen in the code delegate, while an unset toggle keeps the code value. The - options are bound eagerly at registration time because they decide which handlers are registered and - therefore which capabilities the server advertises. This is additive and backward compatible + choose which capabilities to register. The `CrestAppsMcpHandlerBuilder` provides `WithoutTools()`, + `WithoutPrompts()`, and `WithoutResources()`, making it possible to expose a read-only knowledge-base + server (prompts and resources only) or any subset of capabilities. This change is additive: the + parameterless `WithCrestAppsHandlers()` still registers every capability +- reworks tool exposure into an opt-in allow-list driven by the `McpServerOptions` site settings. Nothing + is exposed by default: an MCP server lists and invokes only the tools and configured + [tool instances](../core/tool-instances.md) named in `McpServerOptions.Tools`, or every non-hidden tool + and instance when `McpServerOptions.ExposeAllTools` is `true`. The allow-list is enforced by both the + list and call handlers, so a tool that is not exposed can neither be discovered nor invoked. Because + `McpServerOptions` is backed by site settings, operators choose which tools to expose from the admin + settings UI without redeploying. The MVC and Blazor sample hosts add an "Exposed tools" editor to their + MCP server settings -## Documentation search tool +## Documentation search tool instances -- adds an opt-in `search_documentation` AI tool (in `CrestApps.Core.AI.Mcp`) that searches one or more - configured documentation sites (such as Docusaurus or MkDocs) as a knowledge base and returns the most - relevant passages with their source URLs. Register it with `AddCoreAIDocumentationSearch(...)` or the - `AddDocumentationSearch(...)` MCP server builder method; it is not registered by any default AI - registration. Sites are declared in code with `AddSite(...)` or bound from configuration through - `DocumentationSearchOptions`, and custom sources can be plugged in by implementing `IDocumentationSource`. - The tool is registered under the `knowledgebase` category so a read-only knowledge-base MCP server can - expose it selectively with `WithToolsInCategory("knowledgebase")` -- adds two additional documentation search strategies alongside the sitemap crawler, each with its own - builder method and configuration model: `AddSearchIndex(...)` downloads a prebuilt search index - published as JSON (for example a MkDocs Material `search_index.json`) and ranks it locally, and - `AddAlgoliaDocSearch(...)` forwards queries to the hosted Algolia DocSearch API used by many - Docusaurus sites. Both also bind from configuration through the new `SearchIndexes` and - `AlgoliaSources` lists on `DocumentationSearchOptions` -- adds store-backed documentation sources so operators can add, edit, and remove sources at runtime - (through an admin UI or directly in the database) in addition to declaring them in code. Documentation - sources persisted as `DocumentationSourceEntry` catalog entries are aggregated alongside options-defined - and custom sources. Enable a backend on the documentation search builder with `AddYesSqlStores()` or - `AddEntityCoreStores()`. The `Strategy` field selects how each entry is materialized (`sitemap`, - `search-index`, or `algolia`, defined by `DocumentationSourceStrategies`) through the matching - `IDocumentationSourceFactory`; new strategies can be added by registering an additional factory. Entries - are validated (strategy and its required fields, unique name) by the catalog handler and managed through - `INamedSourceCatalogManager`. The provider caches each materialized source and - rebuilds it only when its entry changes, so database edits are picked up on the next search -- reworks `IDocumentationSourceProvider.GetSources()` into the asynchronous - `GetSourcesAsync(IServiceProvider, CancellationToken)` so the tool can pass its request scope to the - singleton provider and resolve scoped services (the documentation source catalog and custom sources). - This interface was introduced in this same in-development cycle and has not shipped in a stable release, - so it is not a breaking change against 1.0.0 +- replaces the earlier single `search_documentation` tool with documentation search + [tool instance sources](../core/tool-instances.md), so a host exposes one callable search function per + documentation site it configures. Register the sources on the tool instances builder with + `AddDocumentationSearchSources()` (or the individual `AddSitemapDocumentationSource()`, + `AddSearchIndexDocumentationSource()`, and `AddAlgoliaDocumentationSource()` methods). Each configured + instance binds one site and surfaces as a distinct function the AI model can call, and the instances are + managed and persisted through the existing tool instance store (YesSql or Entity Framework Core) and UI. + The MVC and Blazor sample-host tool instance editors add source-specific field groups so operators can + configure a documentation site (base URL, sitemap or index URL, Algolia application/index/search-only + key, and per-instance result limits) directly from the create and edit forms +- ships three documentation search strategies, each as its own source and settings model: the sitemap + source crawls a site through its `sitemap.xml` (for example a public Docusaurus site such as + `core.crestapps.com`), the search-index source downloads a prebuilt JSON search index (for example a + MkDocs Material `search_index.json`) and ranks it locally, and the Algolia source forwards queries to + the hosted Algolia DocSearch API. A singleton materializer caches the crawled corpus or downloaded index + per instance and rebuilds it only when the instance changes, so the corpus is reused across calls +- exposes documentation search functions through the MCP server the same way as any other tool instance: + add the instance name to `McpServerOptions.Tools` (or enable `ExposeAllTools`) to make it discoverable + and callable ## Fixes +- makes the MCP tool allow-list respond to site-settings changes at runtime. The list and call handlers + now read `McpServerOptions` through `IOptionsMonitor` instead of the cached `IOptions`, so exposing or + removing a tool from the admin settings page takes effect without restarting the host +- lets an MCP client invoke a code tool advertised under a function name that differs from its + registration key. The call handler resolves the tool by the published function name (mirroring the list + handler) so a listed tool can always be called +- rethrows cancellation from the documentation search function instead of reporting it as a successful + error result, and returns a stable, non-leaking failure message for other errors - fixes post-session processing endlessly retrying and eventually failing when the AI returned a successful (HTTP 200) response that could not be parsed into structured task results. The no-tools structured output path now records a `Failed` result with a diagnostic message instead of silently returning no result, so these responses no longer exhaust all retry attempts. The unparseable-response case is now logged at `Warning` (including a preview of the raw AI response) instead of only at `Debug`, and the recorded task error message now explains that the AI produced no parseable result or there was no content to evaluate. - avoids issuing a post-session AI request when there is no meaningful user content to evaluate. Sessions whose user prompts are empty, whitespace-only, or system-generated are skipped, so a real AI call is only made when there is something to analyze. diff --git a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md index 17736563..37ade291 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md +++ b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md @@ -2,210 +2,97 @@ sidebar_label: Documentation Search sidebar_position: 5 title: Documentation Search -description: Expose an opt-in AI tool that searches one or more public documentation sites as a knowledge base. +description: Register tool instance sources that search public documentation sites as a knowledge base. --- # Documentation Search -> Register an opt-in AI tool that searches one or more configured documentation sites (such as Docusaurus or MkDocs) and returns the most relevant passages with their source URLs. +> Register documentation search [tool instance](../core/tool-instances.md) sources so operators can configure one callable search function per documentation site (such as Docusaurus or MkDocs) and expose them through an MCP server. ## Problem & Solution A knowledge-base MCP server often needs to answer questions from product or framework documentation that lives on public sites. Instead of indexing that content into a vector store, the documentation -search tool lets you declare a set of documentation sites and scan them on demand. The tool is -**opt-in** — it is not registered by any default AI registration, so it only appears when you call -`AddCoreAIDocumentationSearch(...)` (or the `AddDocumentationSearch(...)` builder method). This makes -it a good fit for a read-only knowledge-base server that should search documentation but perform no -actions. +search sources let an operator declare a documentation site as a **tool instance** and scan it on +demand. + +Rather than a single fixed tool, documentation search ships as [tool instance sources](../core/tool-instances.md): +developer-authored blueprints that a user configures one or more times. Each configured instance binds +one site and surfaces as its own callable function, so a host can offer "search the CrestApps docs" and +"search the Orchard Core docs" as two distinct tools. Instances are persisted and managed through the +standard tool instance store and UI, and are exposed to MCP clients through the server's +[allow-list](./server.md#tool-exposure) — nothing is exposed until you opt in. ## Quick Start +Register the documentation search sources on the tool instances builder: + ```csharp builder.Services.AddCrestAppsCore(crestApps => crestApps .AddAISuite(ai => ai + .AddOpenAI() + .AddToolInstances(toolInstances => toolInstances + .AddDocumentationSearchSources() + .AddYesSqlStores() + ) .AddMcpServer(mcpServer => mcpServer .AddYesSqlStores() - .AddDocumentationSearch(docs => docs - .AddSite("crestapps", "https://core.crestapps.com") - .AddSite("orchardcore", "https://docs.orchardcore.net") - ) ) ) + .AddYesSqlDataStore(configuration => configuration + .UseSqLite("Data Source=app.db;Cache=Shared") + ) ); ``` -Or register it directly on the service collection: +`AddDocumentationSearchSources()` registers all three built-in sources. To register only the ones you +need, call the individual methods instead: ```csharp -builder.Services.AddCoreAIDocumentationSearch(docs => docs - .AddSite("crestapps", "https://core.crestapps.com")); +.AddToolInstances(toolInstances => toolInstances + .AddSitemapDocumentationSource() + .AddSearchIndexDocumentationSource() + .AddAlgoliaDocumentationSource()) ``` -The tool is registered under the name `search_documentation` with the category `knowledgebase` and the -purpose `data_source_search`. Because it carries a category, a knowledge-base MCP server can expose it -selectively: - -```csharp -_ = builder.Services.AddMcpServer() - .WithHttpTransport() - .WithCrestAppsHandlers(handlers => handlers - .WithToolsInCategory(DocumentationSearchFunction.Category)); -``` - -See [MCP Server](./server.md#selecting-which-capabilities-to-expose) for capability and tool selection. - -## Configuring Sites +Once the sources are registered, operators create configured instances (each bound to one site) through +the tool instances UI or store. Each instance becomes a callable function the AI model can invoke. -Sites can be declared in code with `AddSite(...)`, or bound from configuration by configuring -`DocumentationSearchOptions`: +## Search Strategies -```csharp -builder.Services.AddCoreAIDocumentationSearch(); -builder.Services.Configure( - builder.Configuration.GetSection("DocumentationSearch")); -``` +A documentation site can be indexed in different ways depending on what the generator publishes. Each +strategy is its own source with its own settings model, so you pick the one that matches the site. -```json -{ - "DocumentationSearch": { - "MaxResultsPerSite": 5, - "MaxPagesPerSite": 200, - "CacheDuration": "01:00:00", - "Sites": [ - { "Name": "crestapps", "BaseUrl": "https://core.crestapps.com" }, - { "Name": "orchardcore", "BaseUrl": "https://docs.orchardcore.net" } - ] - } -} -``` +| Source | Registration | Best for | How it works | +|--------|--------------|----------|--------------| +| Sitemap crawl | `AddSitemapDocumentationSource()` | Any site that publishes `sitemap.xml` (Docusaurus, MkDocs, and most static sites). | Crawls pages, strips HTML, and ranks locally with keyword scoring. | +| Search index | `AddSearchIndexDocumentationSource()` | MkDocs Material and other sites that publish a fetchable `search_index.json`. | Downloads the prebuilt index once and ranks its entries locally. | +| Algolia DocSearch | `AddAlgoliaDocumentationSource()` | Docusaurus sites (and others) wired to hosted Algolia DocSearch. | Forwards the query to Algolia, which performs the ranking. | -The built-in crawler discovers pages through the site's `sitemap.xml`. Supply `SitemapUrl` on a site to -override the default `{BaseUrl}/sitemap.xml` location. Both Docusaurus and MkDocs publish a standard -sitemap, so no generator-specific configuration is required. +The registered source names are defined by `DocumentationToolConstants` +(`sitemap-documentation`, `search-index-documentation`, `algolia-documentation`), and all three carry +the `Knowledgebase` category. -### DocumentationSearchOptions +### Sitemap crawl settings -| Property | Default | Description | -|----------|---------|-------------| -| `Sites` | empty | The public documentation sites the crawler scans through their sitemap. | -| `SearchIndexes` | empty | The documentation sites that publish a prebuilt search index as JSON. | -| `AlgoliaSources` | empty | The documentation sites searchable through the Algolia DocSearch API. | -| `MaxResultsPerSite` | `5` | Default maximum results a single site contributes to a search. | -| `MaxPagesPerSite` | `200` | Default maximum pages the crawler indexes per site. | -| `MaxConcurrentRequests` | `4` | Maximum concurrent page requests per site while crawling. | -| `CacheDuration` | `1 hour` | How long a crawled or downloaded site corpus is cached before it is refreshed. | - -### DocumentationSite +`SitemapDocumentationToolSettings` binds a sitemap-crawl instance: | Property | Description | |----------|-------------| -| `Name` | Unique logical name; a caller can scope a search to this source. | -| `BaseUrl` | Base URL of the documentation site. | -| `SitemapUrl` | Optional explicit sitemap URL. | -| `MaxResults` | Optional per-site override for the maximum results. | -| `MaxPages` | Optional per-site override for the maximum indexed pages. | - -The first search against a site crawls its pages and caches the corpus in memory for `CacheDuration`; -subsequent searches reuse the cache. Ranking uses lightweight keyword scoring. - -## Search Strategies - -A single documentation site can be indexed in different ways depending on what the generator publishes. -Each strategy has its own builder method and configuration model, so you choose the one that matches the -site — there is no single "kind" switch to configure. - -| Strategy | Builder method | Best for | How it works | -|----------|----------------|----------|--------------| -| Sitemap crawl | `AddSite(...)` | Any site that publishes `sitemap.xml` (Docusaurus, MkDocs, and most static sites). | Crawls pages, strips HTML, and ranks locally with keyword scoring. | -| Search index | `AddSearchIndex(...)` | MkDocs Material and other sites that publish a fetchable `search_index.json`. | Downloads the prebuilt index once and ranks its entries locally. | -| Algolia DocSearch | `AddAlgoliaDocSearch(...)` | Docusaurus sites (and others) wired to hosted Algolia DocSearch. | Forwards the query to Algolia, which performs the ranking. | - -### Example: a public Docusaurus site - -A public Docusaurus site that requires no authentication — such as -[core.crestapps.com](https://core.crestapps.com) — only needs the sitemap crawl strategy. Docusaurus -publishes a standard `sitemap.xml` at the site root, so `AddSite(...)` is all that is required: give the -source a logical name and the site's base URL, and the crawler discovers `{BaseUrl}/sitemap.xml` -automatically. - -```csharp -builder.Services.AddCoreAIDocumentationSearch(docs => docs - .AddSite("crestapps-core", "https://core.crestapps.com")); -``` - -Or on the MCP server builder for a read-only knowledge-base server: - -```csharp -builder.Services.AddCrestAppsCore(crestApps => crestApps - .AddAISuite(ai => ai - .AddMcpServer(mcpServer => mcpServer - .AddYesSqlStores() - .AddDocumentationSearch(docs => docs - .AddSite("crestapps-core", "https://core.crestapps.com") - ) - ) - ) -); -``` - -You can tune how much of the site is indexed and scope results with the optional `configure` action: - -```csharp -.AddDocumentationSearch(docs => docs - .AddSite("crestapps-core", "https://core.crestapps.com", site => - { - // Only needed if the sitemap is not at {BaseUrl}/sitemap.xml. - site.SitemapUrl = "https://core.crestapps.com/sitemap.xml"; - site.MaxPages = 300; // Cap the number of pages crawled. - site.MaxResults = 5; // Cap the results this site contributes per search. - })) -``` +| `BaseUrl` | Base URL of the documentation site (for example `https://core.crestapps.com`). | +| `SitemapUrl` | Optional explicit sitemap URL. Defaults to `{BaseUrl}/sitemap.xml`. | +| `MaxResults` | Optional maximum results this instance returns per search. | +| `MaxPages` | Optional maximum pages the crawler indexes for this site. | -The same site can also be declared in configuration instead of code: +### Search index settings -```json -{ - "DocumentationSearch": { - "Sites": [ - { "Name": "crestapps-core", "BaseUrl": "https://core.crestapps.com" } - ] - } -} -``` +`SearchIndexDocumentationToolSettings` binds a search-index instance: -Because the site is public, no headers, API keys, or credentials are involved — the crawler issues -plain anonymous `GET` requests through the source's resilient `HttpClient`. The first search crawls the -site and caches the corpus for `CacheDuration`; later searches reuse the cache. - -:::tip -Prefer the sitemap crawl for a public Docusaurus site. Only reach for `AddAlgoliaDocSearch(...)` when -the site is wired to hosted Algolia DocSearch and you have its application ID, search-only API key, and -index name. -::: - -### Search Index Source - -MkDocs Material publishes a fetchable `search_index.json`. `AddSearchIndex(...)` downloads that index -once, caches it for `CacheDuration`, and ranks its entries with the same keyword scoring as the crawler -— without fetching every page individually. - -```csharp -.AddDocumentationSearch(docs => docs - .AddSearchIndex("mkdocs", "https://www.mkdocs.org", site => - { - // Optional. Defaults to {BaseUrl}/search/search_index.json. - site.IndexUrl = "https://www.mkdocs.org/search/search_index.json"; - site.MaxResults = 5; - })) -``` - -| `DocumentationSearchIndexSite` property | Description | +| Property | Description | |----------|-------------| -| `Name` | Unique logical name; a caller can scope a search to this source. | | `BaseUrl` | Base URL used to resolve relative entry locations and the default index URL. | | `IndexUrl` | Optional explicit index URL. Defaults to `{BaseUrl}/search/search_index.json`. | -| `MaxResults` | Optional per-site override for the maximum results. | +| `MaxResults` | Optional maximum results this instance returns per search. | :::note This targets the MkDocs Material `search_index.json` schema (`{ "docs": [ { "location", "title", "text" } ] }`). @@ -213,137 +100,83 @@ Docusaurus' `@easyops-cn/docusaurus-search-local` plugin stores a client-side Lu cleanly fetchable JSON document, so use the sitemap crawl or Algolia DocSearch for Docusaurus sites. ::: -### Algolia DocSearch Source - -Many Docusaurus sites use hosted Algolia DocSearch rather than a fetchable index. `AddAlgoliaDocSearch(...)` -forwards each query to the Algolia query API and maps the returned hits to results. Because Algolia -performs the ranking, this source issues a live query per search and does not crawl or cache a corpus. +### Algolia DocSearch settings -```csharp -.AddDocumentationSearch(docs => docs - .AddAlgoliaDocSearch( - name: "docusaurus", - applicationId: "YOUR_APP_ID", - apiKey: "YOUR_SEARCH_ONLY_API_KEY", - indexName: "your-index", - site => site.MaxResults = 5)) -``` +`AlgoliaDocumentationToolSettings` binds an Algolia DocSearch instance: -| `AlgoliaDocSearchSite` property | Description | +| Property | Description | |----------|-------------| -| `Name` | Unique logical name; a caller can scope a search to this source. | | `ApplicationId` | Algolia application identifier. | | `ApiKey` | Algolia **search-only** API key (never a write key). | | `IndexName` | Algolia index name to query. | -| `MaxResults` | Optional per-site override for the maximum results. | +| `MaxResults` | Optional maximum results this instance returns per search. | -Each strategy also binds from configuration through the matching `SearchIndexes` and `AlgoliaSources` -lists on `DocumentationSearchOptions`, mirroring the `Sites` list shown above. +## Example: a public Docusaurus site -## Custom Sources +A public Docusaurus site that requires no authentication — such as +[core.crestapps.com](https://core.crestapps.com) — only needs the sitemap crawl source. Docusaurus +publishes a standard `sitemap.xml` at the site root, so the crawler discovers `{BaseUrl}/sitemap.xml` +automatically. -Implement `IDocumentationSource` to search anything that is not a public sitemap-based site (for -example a local corpus, a search API, or a vector index) and register it with `AddSource`: +1. Register the sitemap source (or all sources) as shown in [Quick Start](#quick-start). +2. Create a tool instance from the **Documentation search (sitemap)** source with: + - **Name**: `crestapps-docs` (this is the name you expose to MCP clients) + - **Description**: a clear sentence such as *"Searches the CrestApps.Core documentation."* + - **Base URL**: `https://core.crestapps.com` +3. Expose the instance through the MCP server by adding its name to the allow-list: ```csharp -public sealed class MyDocsSource : IDocumentationSource +services.Configure(options => { - public string Name => "my-docs"; - - public Task> SearchAsync( - DocumentationSearchRequest request, - CancellationToken cancellationToken) - { - // Return matches ordered by descending Score. - } -} + options.Tools = ["crestapps-docs"]; +}); ``` -```csharp -.AddDocumentationSearch(docs => docs - .AddSource() - .AddSite("crestapps", "https://core.crestapps.com")) -``` - -Custom sources and configured sites are aggregated by `IDocumentationSourceProvider`. When the model -calls the tool without a `source` argument, every source is searched and results are merged by score; -passing a `source` argument scopes the search to that single named source. - -## Store-backed Sources - -Sites registered with `AddSite`, `AddSearchIndex`, and `AddAlgoliaDocSearch` are defined in code (or -bound from configuration). If you want operators to add, edit, and remove documentation sources at -runtime — through an admin UI or directly in the database — persist them in a store instead. +Because the site is public, no headers, API keys, or credentials are involved — the crawler issues +plain anonymous `GET` requests through a resilient `HttpClient`. The first search crawls the site and +caches the corpus; later searches reuse the cache. -Add a store backend to the documentation search builder: +:::tip +Prefer the sitemap crawl for a public Docusaurus site. Only reach for the Algolia source when the site +is wired to hosted Algolia DocSearch and you have its application ID, search-only API key, and index +name. +::: -```csharp -// YesSql -.AddDocumentationSearch(docs => docs - .AddYesSqlStores()) +## Exposing documentation search through MCP -// Entity Framework Core -.AddDocumentationSearch(docs => docs - .AddEntityCoreStores()) -``` +Documentation search functions are exposed like any other tool instance. Add the instance name to +`McpServerOptions.Tools`, or set `McpServerOptions.ExposeAllTools = true` to expose every non-hidden +tool and instance. Because `McpServerOptions` is backed by site settings, operators can manage the +allow-list from the admin **Settings → MCP server** page. See +[MCP Server → Tool Exposure](./server.md#tool-exposure) for details. -Store-backed sources and code/options-defined sources are aggregated together, so you can seed a few -sites in code and let operators manage the rest from the database. +## Corpus caching -Each stored source is a `DocumentationSourceEntry`. The `Strategy` field (stored in `Source`) selects -how the entry is materialized and must match a registered `IDocumentationSourceFactory.Strategy`. The -built-in strategies are defined by `DocumentationSourceStrategies`: +The runtime documentation source (the crawled corpus or downloaded index) is built lazily and cached by +a singleton `IDocumentationSourceMaterializer`, keyed by the instance identifier. The cache is rebuilt +only when the instance changes, so an edit to a site's settings is picked up on the next search while an +unchanged instance reuses its corpus across calls. -| Strategy (`DocumentationSourceStrategies`) | Value | Required fields | -| --- | --- | --- | -| `Sitemap` | `sitemap` | `BaseUrl` (optional `SitemapUrl`, `MaxResults`, `MaxPages`) | -| `SearchIndex` | `search-index` | `BaseUrl` (optional `IndexUrl`, `MaxResults`) | -| `Algolia` | `algolia` | `ApplicationId`, `ApiKey`, `IndexName` (optional `MaxResults`) | +## Adding a new documentation source -Create and manage entries through the named-source catalog manager, which validates the strategy and -its required fields and enforces a unique name: - -```csharp -public sealed class DocumentationSourceService -{ - private readonly INamedSourceCatalogManager _manager; - - public DocumentationSourceService(INamedSourceCatalogManager manager) - { - _manager = manager; - } - - public async Task AddSitemapAsync(string name, string baseUrl, CancellationToken cancellationToken) - { - var entry = await _manager.NewAsync(name, DocumentationSourceStrategies.Sitemap, cancellationToken: cancellationToken); - entry.BaseUrl = baseUrl; - - var validation = await _manager.ValidateAsync(entry, cancellationToken); - - if (validation.Succeeded) - { - await _manager.CreateAsync(entry, cancellationToken); - } - } -} -``` +To support a documentation site that none of the built-in strategies cover, implement a new +[tool instance source](../core/tool-instances.md): -The provider rebuilds a stored source only when its entry changes (tracked by the entry's modified -timestamp), so an edit in the database is picked up on the next search without restarting the host. +1. Create a settings model for the user-provided configuration. +2. Implement `IAIToolInstanceSource.CreateTool(AIToolInstance)` to read the settings and return a + `DocumentationSearchToolFunction` (or your own `AIFunction`) bound to a concrete `IDocumentationSource`. +3. Register the source on the tool instances builder with `AddSource(name, configure)`. -To register a new strategy that can be stored in the catalog, implement `IDocumentationSourceFactory` -with a new `Strategy` identifier and register it as an `IDocumentationSourceFactory`. +Operators then create instances of your new source exactly like the built-in ones, and expose them +through the same MCP allow-list. ## How It Works -1. `AddCoreAIDocumentationSearch(...)` registers the `search_documentation` tool, the - `DefaultDocumentationSourceProvider`, the strategy factories, the documentation source catalog and - manager, and a named `HttpClient` with standard resilience. -2. When invoked, the tool resolves `IDocumentationSourceProvider` to get all sources: custom sources, - the options-defined sites, and any entries persisted in the documentation source catalog — each - materialized through its `IDocumentationSourceFactory` (a `SitemapDocumentationSource`, - `SearchIndexDocumentationSource`, or `AlgoliaDocumentationSource`). -3. Each source is searched in parallel; a failing source is skipped so one broken site does not fail - the whole search. -4. Results are merged, ordered by descending relevance, and returned with their titles and URLs so the - model can cite them. +1. `AddDocumentationSearchSources()` registers the three tool instance sources, a singleton + `IDocumentationSourceMaterializer`, and a named `HttpClient` with standard resilience. +2. An operator configures one or more `AIToolInstance` entries, each bound to a single site through its + settings. +3. When the MCP server lists or calls tools, allow-listed instances are materialized through their + keyed `IAIToolInstanceSource`, which produces a `DocumentationSearchToolFunction`. +4. On invocation, the function resolves (and caches) the concrete `IDocumentationSource`, searches it, + and returns the ranked results with their titles and URLs so the model can cite them. diff --git a/src/CrestApps.Core.Docs/docs/mcp/server.md b/src/CrestApps.Core.Docs/docs/mcp/server.md index 870d3b00..d63c2eaf 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/server.md +++ b/src/CrestApps.Core.Docs/docs/mcp/server.md @@ -299,34 +299,37 @@ services.Configure(options => ## Tool Exposure -When your application acts as an MCP server, registered AI tools are exposed to external clients. The server endpoint's `WithListToolsHandler` and `WithCallToolHandler` callbacks delegate to your tool registry: +When your application acts as an MCP server, tool exposure is **opt-in**. Nothing is listed or callable by default: the server only exposes the tools and configured [tool instances](../core/tool-instances.md) that you explicitly allow through the `McpServerOptions` site settings. -1. **List tools** — Returns metadata (name, description, JSON schema) for all registered tools -2. **Call tool** — Resolves the tool by name from the registry and invokes it with the provided arguments +```csharp +services.Configure(options => +{ + // Expose specific tools and tool instances by name. + options.Tools = ["crestapps-docs", "weather"]; + + // Or expose every non-hidden tool and tool instance. + options.ExposeAllTools = true; +}); +``` + +| Property | Effect | +|----------|--------| +| `Tools` | An allow-list of tool and tool instance names to expose. Matching is case-insensitive. | +| `ExposeAllTools` | When `true`, every non-hidden tool and tool instance is exposed and the allow-list is ignored. | -Tools registered via `AddCoreAITool()` (see [Custom Tools](../core/tools.md)) are automatically available to MCP clients unless they are marked with `.Hidden()`. Hidden tools remain available to explicitly configured profiles and agents, but the shared MCP handlers do not list or invoke them directly. +Because `McpServerOptions` is backed by site settings, an operator can choose which tools to expose from the admin **Settings → MCP server** page without redeploying. The allow-list is enforced by **both** the list and call handlers, so a tool that is not exposed can neither be discovered nor invoked. `.Hidden()` tools are always excluded, even when `ExposeAllTools` is `true`. + +Both code-registered tools (from `AddCoreAITool()`, see [Custom Tools](../core/tools.md)) and stored tool instances (from any registered [tool instance source](../core/tool-instances.md), such as the documentation search sources) participate in the same allow-list. ### Selecting which capabilities to expose -`WithCrestAppsHandlers()` accepts an optional configuration delegate so a host can choose which capabilities (tools, prompts, resources) are wired in, and which tools are exposed. With no delegate, every capability is registered and all non-hidden tools are exposed — the same behavior as before. +`WithCrestAppsHandlers()` accepts an optional configuration delegate so a host can choose which capabilities (tools, prompts, resources) are wired in. With no delegate, every capability is registered. ```csharp -// Read-only knowledgebase server: prompts + resources, no tools +// Read-only knowledge-base server: prompts + resources, no tools _ = builder.Services.AddMcpServer() .WithHttpTransport() .WithCrestAppsHandlers(handlers => handlers.WithoutTools()); - -// Expose only tools in a category, or with a given purpose -_ = builder.Services.AddMcpServer() - .WithHttpTransport() - .WithCrestAppsHandlers(handlers => handlers - .WithToolsInCategory("knowledgebase") - .WithToolsForPurpose(AIToolPurposes.DataSourceSearch)); - -// Expose an explicit allow-list of tools by registered name -_ = builder.Services.AddMcpServer() - .WithHttpTransport() - .WithCrestAppsHandlers(handlers => handlers.WithToolNames("search_documents")); ``` The `CrestAppsMcpHandlerBuilder` exposes: @@ -334,44 +337,11 @@ The `CrestAppsMcpHandlerBuilder` exposes: | Method | Effect | |--------|--------| | `WithoutTools()` | Does not register the tool list/call handlers, so the server exposes no tools. | -| `WithoutSdkTools()` | Excludes SDK `McpServerTool` instances while keeping CrestApps tool handlers. | | `WithoutPrompts()` | Does not register the prompt handlers. | | `WithoutResources()` | Does not register the resource handlers. | -| `WithToolsInCategory(params string[])` | Exposes only tools assigned to one of the categories. | -| `WithToolsForPurpose(params string[])` | Exposes only tools tagged with one of the purposes. | -| `WithToolNames(params string[])` | Exposes only tools whose registered name matches. | -| `FilterTools(Func)` | Exposes only tools matching a custom predicate. | - -Tool filters are applied to **both** the list and call handlers, so a filtered-out tool can neither be discovered nor invoked. Multiple filters are combined with logical AND (a tool must satisfy every filter); values passed within a single call are combined with logical OR. `.Hidden()` tools are always excluded regardless of filters. - -### Toggling capabilities from configuration - -The capability toggles above can also be driven from configuration, so an operator can enable or disable tools, SDK tools, prompts, and resources without changing code. Pass an `IConfiguration` section to `WithCrestAppsHandlers` and it binds `McpServerHandlerOptions`: -```csharp -_ = builder.Services.AddMcpServer() - .WithHttpTransport() - .WithCrestAppsHandlers( - builder.Configuration.GetSection("Mcp:Server:Handlers"), - handlers => handlers.WithToolsInCategory("knowledgebase")); -``` - -```json -{ - "Mcp": { - "Server": { - "Handlers": { - "IncludeTools": true, - "IncludeSdkTools": false, - "IncludePrompts": true, - "IncludeResources": true - } - } - } -} -``` +Capability registration (which handlers exist) is chosen in code, while tool exposure (which tools those handlers surface) is chosen by the `McpServerOptions` allow-list. -`McpServerHandlerOptions` exposes nullable toggles: `IncludeTools`, `IncludeSdkTools`, `IncludePrompts`, and `IncludeResources`. **Configuration wins over code** — any toggle explicitly set in configuration overrides the value chosen in the code delegate, while a toggle left unset (`null`) keeps the code value. Because these toggles decide whether the handlers are registered (and therefore which capabilities the server advertises), they are bound eagerly at registration time. ## Server Metadata diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs b/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs index 9b634e35..db848dbc 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs @@ -1,29 +1,19 @@ -using CrestApps.Core.AI.Tooling; - namespace CrestApps.Core.AI.Mcp; /// -/// Configures which CrestApps MCP protocol handlers are wired into an MCP server and, -/// for tools, which registered tools are exposed to external clients. By default every -/// capability (tools, prompts, and resources) is registered and all non-hidden tools are -/// exposed. Use the fluent methods to opt out of a capability or to narrow the set of tools. +/// Configures which CrestApps MCP protocol handlers are wired into an MCP server. By default every +/// capability (tools, prompts, and resources) is registered. Use the fluent methods to opt out of a +/// capability, for example to expose a read-only knowledgebase server that only serves prompts and +/// resources. Which tools and tool instances are actually listed and callable is controlled separately +/// by the site settings allow-list. /// public sealed class CrestAppsMcpHandlerBuilder { - private List> _toolFilters; - /// /// Gets a value indicating whether the tool list and call handlers are registered. /// public bool IncludeTools { get; private set; } = true; - /// - /// Gets a value indicating whether SDK McpServerTool instances registered in the - /// service provider are merged into the exposed tool set. This only applies when - /// is . - /// - public bool IncludeSdkTools { get; private set; } = true; - /// /// Gets a value indicating whether the prompt list and get handlers are registered. /// @@ -46,18 +36,6 @@ public CrestAppsMcpHandlerBuilder WithoutTools() return this; } - /// - /// Excludes SDK McpServerTool instances from the exposed tool set while keeping the - /// CrestApps tool handlers registered. - /// - /// The same builder instance for chaining. - public CrestAppsMcpHandlerBuilder WithoutSdkTools() - { - IncludeSdkTools = false; - - return this; - } - /// /// Excludes the prompt handlers so the server does not list or serve prompts. /// @@ -79,133 +57,4 @@ public CrestAppsMcpHandlerBuilder WithoutResources() return this; } - - /// - /// Restricts the exposed tools to those matching the supplied predicate. Multiple filters are - /// combined with logical AND, so a tool must satisfy every configured filter to be exposed. - /// - /// The predicate evaluated against each registered tool definition. - /// The same builder instance for chaining. - public CrestAppsMcpHandlerBuilder FilterTools(Func predicate) - { - ArgumentNullException.ThrowIfNull(predicate); - - AddToolFilter((_, entry) => predicate(entry)); - - return this; - } - - /// - /// Restricts the exposed tools to those assigned to one of the supplied categories. Multiple - /// filters are combined with logical AND; the categories within this single call are combined - /// with logical OR. - /// - /// The categories to expose. - /// The same builder instance for chaining. - public CrestAppsMcpHandlerBuilder WithToolsInCategory(params string[] categories) - { - ArgumentNullException.ThrowIfNull(categories); - - AddToolFilter((_, entry) => entry.Category is not null - && Array.Exists(categories, category => string.Equals(category, entry.Category, StringComparison.OrdinalIgnoreCase))); - - return this; - } - - /// - /// Restricts the exposed tools to those tagged with one of the supplied purposes. Multiple - /// filters are combined with logical AND; the purposes within this single call are combined - /// with logical OR. Use well-known constants from or custom strings. - /// - /// The purposes to expose. - /// The same builder instance for chaining. - public CrestAppsMcpHandlerBuilder WithToolsForPurpose(params string[] purposes) - { - ArgumentNullException.ThrowIfNull(purposes); - - AddToolFilter((_, entry) => Array.Exists(purposes, purpose => !string.IsNullOrEmpty(purpose) && entry.HasPurpose(purpose))); - - return this; - } - - /// - /// Restricts the exposed tools to those whose registered name matches one of the supplied names. - /// Multiple filters are combined with logical AND; the names within this single call are combined - /// with logical OR. Matching is ordinal and case-insensitive. - /// - /// The registered tool names to expose. - /// The same builder instance for chaining. - public CrestAppsMcpHandlerBuilder WithToolNames(params string[] names) - { - ArgumentNullException.ThrowIfNull(names); - - AddToolFilter((name, entry) => Array.Exists(names, candidate => - string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase) - || (entry.Name is not null && string.Equals(candidate, entry.Name, StringComparison.OrdinalIgnoreCase)))); - - return this; - } - - /// - /// Determines whether the tool with the supplied name and definition passes every configured filter. - /// - /// The registered tool name (the tool definition dictionary key). - /// The tool definition being evaluated. - /// when the tool should be exposed; otherwise . - internal bool IsToolAllowed(string name, AIToolDefinitionEntry entry) - { - if (_toolFilters is null) - { - return true; - } - - foreach (var filter in _toolFilters) - { - if (!filter(name, entry)) - { - return false; - } - } - - return true; - } - - private void AddToolFilter(Func filter) - { - (_toolFilters ??= []).Add(filter); - } - - /// - /// Overlays the supplied configuration-bound options onto this builder. Only options with an - /// explicit (non-) value are applied, and they override any value already - /// set in code so configuration wins over code for the capability toggles. - /// - /// The configuration-bound options to overlay. - internal void ApplyOptions(McpServerHandlerOptions options) - { - if (options is null) - { - return; - } - - if (options.IncludeTools.HasValue) - { - IncludeTools = options.IncludeTools.Value; - } - - if (options.IncludeSdkTools.HasValue) - { - IncludeSdkTools = options.IncludeSdkTools.Value; - } - - if (options.IncludePrompts.HasValue) - { - IncludePrompts = options.IncludePrompts.Value; - } - - if (options.IncludeResources.HasValue) - { - IncludeResources = options.IncludeResources.Value; - } - } } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSourceFactory.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSourceFactory.cs deleted file mode 100644 index 2e106cd8..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSourceFactory.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Net.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// An that materializes a stored entry into an -/// . -/// -public sealed class AlgoliaDocumentationSourceFactory : IDocumentationSourceFactory -{ - private readonly DocumentationSearchOptions _options; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILoggerFactory _loggerFactory; - - /// - /// Initializes a new instance of the class. - /// - /// The documentation search options. - /// The HTTP client factory. - /// The logger factory. - public AlgoliaDocumentationSourceFactory( - IOptions options, - IHttpClientFactory httpClientFactory, - ILoggerFactory loggerFactory) - { - _options = options.Value; - _httpClientFactory = httpClientFactory; - _loggerFactory = loggerFactory; - } - - /// - public string Strategy => DocumentationSourceStrategies.Algolia; - - /// - public IDocumentationSource Create(DocumentationSourceEntry entry) - { - ArgumentNullException.ThrowIfNull(entry); - - var site = new AlgoliaDocSearchSite - { - Name = entry.Name, - ApplicationId = entry.ApplicationId, - ApiKey = entry.ApiKey, - IndexName = entry.IndexName, - MaxResults = entry.MaxResults, - }; - - return new AlgoliaDocumentationSource( - site, - _options, - _httpClientFactory, - _loggerFactory.CreateLogger()); - } -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSettings.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSettings.cs new file mode 100644 index 00000000..b29da1da --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSettings.cs @@ -0,0 +1,31 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// The user-provided settings for an Algolia DocSearch documentation search tool instance. The settings +/// are persisted in the owning and bind +/// the produced function to a single Algolia index. Only the search-only API key should be supplied here, +/// as it is safe to expose to clients. +/// +public sealed class AlgoliaDocumentationToolSettings +{ + /// + /// Gets or sets the Algolia application identifier. + /// + public string ApplicationId { get; set; } + + /// + /// Gets or sets the Algolia search-only API key. + /// + public string ApiKey { get; set; } + + /// + /// Gets or sets the Algolia index name to query. + /// + public string IndexName { get; set; } + + /// + /// Gets or sets the maximum number of results this instance returns for a single search. When not + /// set, a built-in default is used. + /// + public int? MaxResults { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSource.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSource.cs new file mode 100644 index 00000000..7a8237c6 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSource.cs @@ -0,0 +1,56 @@ +using CrestApps.Core.AI.Tooling; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// The built-in that lets users configure documentation search through +/// the hosted Algolia DocSearch query API (the search used by many Docusaurus sites). Each configured +/// binds one Algolia index; the AI model only supplies the search query. +/// +public sealed class AlgoliaDocumentationToolSource : IAIToolInstanceSource +{ + /// + /// Creates the bound to the supplied instance's settings. + /// + /// The configured tool instance whose settings should be bound to the produced tool. + /// The configured documentation search function. + public AITool CreateTool(AIToolInstance instance) + { + ArgumentNullException.ThrowIfNull(instance); + + var settings = instance.TryGet(out var stored) + ? stored + : new AlgoliaDocumentationToolSettings(); + + var functionName = instance.GetFunctionName(); + var description = string.IsNullOrWhiteSpace(instance.Description) + ? "Searches the configured documentation site and returns the most relevant passages with their source URLs." + : instance.Description; + + return new DocumentationSearchToolFunction(functionName, description, instance, services => + { + var site = new AlgoliaDocSearchSite + { + Name = string.IsNullOrWhiteSpace(instance.Name) + ? functionName + : instance.Name, + ApplicationId = settings.ApplicationId, + ApiKey = settings.ApiKey, + IndexName = settings.IndexName, + MaxResults = settings.MaxResults, + }; + + var options = services.GetService>()?.Value ?? new DocumentationSearchOptions(); + var httpClientFactory = services.GetRequiredService(); + var logger = services.GetService()?.CreateLogger() + ?? NullLogger.Instance; + + return new AlgoliaDocumentationSource(site, options, httpClientFactory, logger); + }); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceCatalog.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceCatalog.cs deleted file mode 100644 index 339e73b3..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceCatalog.cs +++ /dev/null @@ -1,53 +0,0 @@ -using CrestApps.Core.Models; -using CrestApps.Core.Services; - -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// The default multi-source documentation source catalog. Aggregates entries from all registered -/// implementations (for example YesSql or EntityCore stores), -/// deduplicating by name so database-defined sources can be managed alongside custom catalog sources. -/// -public sealed class DefaultDocumentationSourceCatalog : MultiSourceNamedSourceCatalog, IDocumentationSourceCatalog -{ - /// - /// Initializes a new instance of the class. - /// - /// The registered catalog sources. - public DefaultDocumentationSourceCatalog(IEnumerable> sources) - : base(sources) - { - } - - /// - protected override string GetItemId(DocumentationSourceEntry entry) => entry.ItemId; - - /// - protected override IEnumerable ApplyFilters(QueryContext context, IEnumerable entries) - { - if (context is null) - { - return entries; - } - - if (!string.IsNullOrEmpty(context.Source)) - { - entries = entries.Where(entry => string.Equals(entry.Source, context.Source, StringComparison.OrdinalIgnoreCase)); - } - - if (!string.IsNullOrEmpty(context.Name)) - { - entries = entries.Where(entry => entry.Name is not null && entry.Name.Contains(context.Name, StringComparison.OrdinalIgnoreCase)); - } - - if (context.Sorted) - { - entries = entries.OrderBy(static entry => entry.DisplayText ?? entry.Name, StringComparer.OrdinalIgnoreCase); - } - - return entries; - } - - /// - protected override string GetSortKey(DocumentationSourceEntry entry) => entry.DisplayText ?? entry.Name; -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceMaterializer.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceMaterializer.cs new file mode 100644 index 00000000..3b1c5150 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceMaterializer.cs @@ -0,0 +1,43 @@ +using System.Collections.Concurrent; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// The default . It keeps one cached +/// per key and rebuilds it only when the supplied signature changes, +/// so a crawled corpus or a downloaded search index survives across searches for the lifetime of the +/// application. +/// +public sealed class DefaultDocumentationSourceMaterializer : IDocumentationSourceMaterializer +{ + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + + /// + public IDocumentationSource GetOrCreate(string key, string signature, Func factory) + { + ArgumentException.ThrowIfNullOrEmpty(key); + ArgumentNullException.ThrowIfNull(factory); + + var cached = _cache.AddOrUpdate( + key, + _ => new CachedSource(signature, factory()), + (_, existing) => string.Equals(existing.Signature, signature, StringComparison.Ordinal) + ? existing + : new CachedSource(signature, factory())); + + return cached.Source; + } + + private sealed class CachedSource + { + public CachedSource(string signature, IDocumentationSource source) + { + Signature = signature; + Source = source; + } + + public string Signature { get; } + + public IDocumentationSource Source { get; } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs deleted file mode 100644 index 5b601a3e..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceProvider.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System.Collections.Concurrent; -using System.Globalization; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// Default that combines documentation sources defined in -/// options (through the documentation search builder), documentation sources persisted in a catalog -/// (for example a YesSql or EntityCore store), and custom -/// implementations registered in code. Materialized crawler sources are cached and only rebuilt when -/// their defining entry changes so their in-memory corpus is reused across searches. -/// -public sealed class DefaultDocumentationSourceProvider : IDocumentationSourceProvider -{ - private const string OptionsSignature = "options"; - - private readonly IOptions _options; - private readonly Dictionary _factories; - private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); - - /// - /// Initializes a new instance of the class. - /// - /// The documentation search options. - /// The strategy factories used to materialize stored and options-defined entries. - public DefaultDocumentationSourceProvider( - IOptions options, - IEnumerable factories) - { - _options = options; - - var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var factory in factories) - { - map[factory.Strategy] = factory; - } - - _factories = map; - } - - /// - public async ValueTask> GetSourcesAsync(IServiceProvider services, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(services); - - var sources = new List(); - - foreach (var source in services.GetServices()) - { - sources.Add(source); - } - - var descriptors = new List(); - - CollectOptionsDescriptors(descriptors); - - await CollectCatalogDescriptorsAsync(services, descriptors, cancellationToken); - - var liveKeys = new HashSet(StringComparer.Ordinal); - - foreach (var descriptor in descriptors) - { - liveKeys.Add(descriptor.Key); - - var cached = _cache.AddOrUpdate( - descriptor.Key, - _ => new CachedSource(descriptor.Signature, descriptor.Factory.Create(descriptor.Entry)), - (_, existing) => string.Equals(existing.Signature, descriptor.Signature, StringComparison.Ordinal) - ? existing - : new CachedSource(descriptor.Signature, descriptor.Factory.Create(descriptor.Entry))); - - sources.Add(cached.Source); - } - - foreach (var key in _cache.Keys) - { - if (!liveKeys.Contains(key)) - { - _cache.TryRemove(key, out _); - } - } - - return sources; - } - - private void CollectOptionsDescriptors(List descriptors) - { - var options = _options.Value; - - if (_factories.TryGetValue(DocumentationSourceStrategies.Sitemap, out var sitemapFactory)) - { - foreach (var site in options.Sites) - { - if (string.IsNullOrWhiteSpace(site.Name) || string.IsNullOrWhiteSpace(site.BaseUrl)) - { - continue; - } - - var entry = new DocumentationSourceEntry - { - Name = site.Name, - Source = DocumentationSourceStrategies.Sitemap, - BaseUrl = site.BaseUrl, - SitemapUrl = site.SitemapUrl, - MaxResults = site.MaxResults, - MaxPages = site.MaxPages, - }; - - descriptors.Add(new MaterializedDescriptor($"options:sitemap:{site.Name}", OptionsSignature, sitemapFactory, entry)); - } - } - - if (_factories.TryGetValue(DocumentationSourceStrategies.SearchIndex, out var searchIndexFactory)) - { - foreach (var site in options.SearchIndexes) - { - if (string.IsNullOrWhiteSpace(site.Name) || string.IsNullOrWhiteSpace(site.BaseUrl)) - { - continue; - } - - var entry = new DocumentationSourceEntry - { - Name = site.Name, - Source = DocumentationSourceStrategies.SearchIndex, - BaseUrl = site.BaseUrl, - IndexUrl = site.IndexUrl, - MaxResults = site.MaxResults, - }; - - descriptors.Add(new MaterializedDescriptor($"options:search-index:{site.Name}", OptionsSignature, searchIndexFactory, entry)); - } - } - - if (_factories.TryGetValue(DocumentationSourceStrategies.Algolia, out var algoliaFactory)) - { - foreach (var site in options.AlgoliaSources) - { - if (string.IsNullOrWhiteSpace(site.Name) - || string.IsNullOrWhiteSpace(site.ApplicationId) - || string.IsNullOrWhiteSpace(site.ApiKey) - || string.IsNullOrWhiteSpace(site.IndexName)) - { - continue; - } - - var entry = new DocumentationSourceEntry - { - Name = site.Name, - Source = DocumentationSourceStrategies.Algolia, - ApplicationId = site.ApplicationId, - ApiKey = site.ApiKey, - IndexName = site.IndexName, - MaxResults = site.MaxResults, - }; - - descriptors.Add(new MaterializedDescriptor($"options:algolia:{site.Name}", OptionsSignature, algoliaFactory, entry)); - } - } - } - - private async Task CollectCatalogDescriptorsAsync(IServiceProvider services, List descriptors, CancellationToken cancellationToken) - { - var catalog = services.GetService(); - - if (catalog is null) - { - return; - } - - var entries = await catalog.GetAllAsync(cancellationToken); - - foreach (var entry in entries) - { - if (string.IsNullOrWhiteSpace(entry.Source) || !_factories.TryGetValue(entry.Source, out var factory)) - { - continue; - } - - var signature = (entry.ModifiedUtc ?? entry.CreatedUtc).Ticks.ToString(CultureInfo.InvariantCulture); - - descriptors.Add(new MaterializedDescriptor($"catalog:{entry.ItemId}", signature, factory, entry)); - } - } - - private sealed class MaterializedDescriptor - { - public MaterializedDescriptor( - string key, - string signature, - IDocumentationSourceFactory factory, - DocumentationSourceEntry entry) - { - Key = key; - Signature = signature; - Factory = factory; - Entry = entry; - } - - public string Key { get; } - - public string Signature { get; } - - public IDocumentationSourceFactory Factory { get; } - - public DocumentationSourceEntry Entry { get; } - } - - private sealed class CachedSource - { - public CachedSource(string signature, IDocumentationSource source) - { - Signature = signature; - Source = source; - } - - public string Signature { get; } - - public IDocumentationSource Source { get; } - } -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchBuilder.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchBuilder.cs deleted file mode 100644 index bfe11e27..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchBuilder.cs +++ /dev/null @@ -1,147 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// A fluent builder for configuring the documentation search sources that the documentation search -/// tool can scan. Register public documentation sites through -/// or plug in custom sources through the AddSource overloads. -/// -public sealed class DocumentationSearchBuilder -{ - /// - /// Initializes a new instance of the class. - /// - /// The service collection. - public DocumentationSearchBuilder(IServiceCollection services) - { - Services = services; - } - - /// - /// Gets the used to register documentation search services. - /// - public IServiceCollection Services { get; } - - /// - /// Registers a public documentation site that the built-in crawler scans through its - /// sitemap.xml. - /// - /// The unique logical name of the site. - /// The base URL of the documentation site. - /// An optional action used to further configure the site. - /// The same builder instance for chaining. - public DocumentationSearchBuilder AddSite(string name, string baseUrl, Action configure = null) - { - ArgumentException.ThrowIfNullOrEmpty(name); - ArgumentException.ThrowIfNullOrEmpty(baseUrl); - - Services.Configure(options => - { - var site = new DocumentationSite - { - Name = name, - BaseUrl = baseUrl, - }; - - configure?.Invoke(site); - - options.Sites.Add(site); - }); - - return this; - } - - /// - /// Registers a documentation site that publishes a prebuilt search index as JSON (for example a - /// MkDocs Material search_index.json). The built-in source downloads the index once and - /// ranks its entries with keyword scoring. - /// - /// The unique logical name of the site. - /// The base URL of the documentation site, used to resolve result URLs. - /// An optional action used to further configure the site. - /// The same builder instance for chaining. - public DocumentationSearchBuilder AddSearchIndex(string name, string baseUrl, Action configure = null) - { - ArgumentException.ThrowIfNullOrEmpty(name); - ArgumentException.ThrowIfNullOrEmpty(baseUrl); - - Services.Configure(options => - { - var site = new DocumentationSearchIndexSite - { - Name = name, - BaseUrl = baseUrl, - }; - - configure?.Invoke(site); - - options.SearchIndexes.Add(site); - }); - - return this; - } - - /// - /// Registers a documentation site that is searchable through the Algolia DocSearch query API (the - /// hosted search used by many Docusaurus sites). - /// - /// The unique logical name of the site. - /// The Algolia application identifier. - /// The Algolia search-only API key. - /// The Algolia index name to query. - /// An optional action used to further configure the site. - /// The same builder instance for chaining. - public DocumentationSearchBuilder AddAlgoliaDocSearch(string name, string applicationId, string apiKey, string indexName, Action configure = null) - { - ArgumentException.ThrowIfNullOrEmpty(name); - ArgumentException.ThrowIfNullOrEmpty(applicationId); - ArgumentException.ThrowIfNullOrEmpty(apiKey); - ArgumentException.ThrowIfNullOrEmpty(indexName); - - Services.Configure(options => - { - var site = new AlgoliaDocSearchSite - { - Name = name, - ApplicationId = applicationId, - ApiKey = apiKey, - IndexName = indexName, - }; - - configure?.Invoke(site); - - options.AlgoliaSources.Add(site); - }); - - return this; - } - - /// - /// Registers a custom documentation source implementation. - /// - /// The documentation source type. - /// The same builder instance for chaining. - public DocumentationSearchBuilder AddSource() - where TSource : class, IDocumentationSource - { - Services.TryAddEnumerable(ServiceDescriptor.Singleton()); - - return this; - } - - /// - /// Registers a custom documentation source instance. - /// - /// The documentation source instance. - /// The same builder instance for chaining. - public DocumentationSearchBuilder AddSource(IDocumentationSource source) - { - ArgumentNullException.ThrowIfNull(source); - - Services.TryAddEnumerable(ServiceDescriptor.Singleton(source)); - - return this; - } -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs index 207098e2..1af96073 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs @@ -1,29 +1,12 @@ namespace CrestApps.Core.AI.Mcp.Documentation; /// -/// Options that control the built-in documentation search sources. Configure sites in code through -/// the documentation search builder, or bind this type from configuration to declare the public -/// documentation sites the search tool is allowed to scan. +/// Runtime limits that control how a built-in documentation search source crawls and ranks a single +/// documentation site. A fresh instance with sensible defaults is created for each configured +/// documentation search tool instance. /// public sealed class DocumentationSearchOptions { - /// - /// Gets the collection of public documentation sites that the built-in crawler scans. - /// - public IList Sites { get; } = []; - - /// - /// Gets the collection of documentation sites that expose a prebuilt search index as JSON (for - /// example MkDocs Material) that the built-in search-index source downloads and ranks. - /// - public IList SearchIndexes { get; } = []; - - /// - /// Gets the collection of documentation sites that are searchable through the Algolia DocSearch - /// query API. - /// - public IList AlgoliaSources { get; } = []; - /// /// Gets or sets the default maximum number of results a single site contributes to a search. /// diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchToolFunction.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchToolFunction.cs new file mode 100644 index 00000000..9d3719ca --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchToolFunction.cs @@ -0,0 +1,205 @@ +using System.Globalization; +using System.Text.Json; +using CrestApps.Core.AI.Extensions; +using CrestApps.Core.AI.Tooling; +using Cysharp.Text; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// An produced by a documentation search tool instance source. Each function is +/// bound to a single configured documentation site and searches only that site, so a host can expose one +/// callable function per documentation source it wants to make available. The runtime +/// is materialized lazily and cached through the +/// so a crawled corpus or downloaded index is reused +/// across calls. +/// +public sealed class DocumentationSearchToolFunction : AIFunction +{ + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query used to find relevant documentation." + }, + "maxResults": { + "type": "integer", + "description": "Optional. The maximum number of results to return." + } + }, + "required": ["query"], + "additionalProperties": false + } + """); + + private readonly string _name; + private readonly string _description; + private readonly AIToolInstance _instance; + private readonly Func _sourceFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The function name exposed to the AI model. + /// The description exposed to the AI model. + /// The configured tool instance the function belongs to. Used to key the materialized source cache. + /// A factory that builds the runtime documentation source from request services. + public DocumentationSearchToolFunction( + string name, + string description, + AIToolInstance instance, + Func sourceFactory) + { + _name = name; + _description = string.IsNullOrWhiteSpace(description) + ? name + : description; + _instance = instance; + _sourceFactory = sourceFactory; + } + + /// + /// Gets the function name exposed to the AI model. + /// + public override string Name => _name; + + /// + /// Gets the description exposed to the AI model. + /// + public override string Description => _description; + + /// + /// Gets the JSON schema describing the arguments the model may supply. + /// + public override JsonElement JsonSchema => _jsonSchema; + + /// + /// Gets additional metadata applied to the function. + /// + public override IReadOnlyDictionary AdditionalProperties { get; } = new Dictionary + { + ["Strict"] = false, + }; + + /// + /// Searches the configured documentation site for the supplied query. + /// + /// The arguments supplied by the AI model. + /// The cancellation token. + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(arguments); + + var services = arguments.Services; + var logger = services?.GetService()?.CreateLogger(); + + if (!arguments.TryGetFirstString("query", out var query) || string.IsNullOrWhiteSpace(query)) + { + logger?.LogWarning("AI tool '{ToolName}' missing required argument 'query'.", _name); + + return "Unable to find a 'query' argument in the arguments parameter."; + } + + if (services is null) + { + return "No services are available to perform the documentation search."; + } + + var request = new DocumentationSearchRequest(query); + + if (arguments.TryGetFirst("maxResults", out var rawMaxResults) && TryConvertToInt32(rawMaxResults, out var maxResults) && maxResults > 0) + { + request.MaxResults = maxResults; + } + + IReadOnlyList results; + + try + { + var materializer = services.GetRequiredService(); + var signature = (_instance.ModifiedUtc ?? _instance.CreatedUtc).Ticks.ToString(CultureInfo.InvariantCulture); + var source = materializer.GetOrCreate(_instance.ItemId, signature, () => _sourceFactory(services)); + + results = await source.SearchAsync(request, cancellationToken) ?? []; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger?.LogWarning(ex, "AI tool '{ToolName}' failed to search documentation.", _name); + + return "The documentation search failed. See the server logs for details."; + } + + if (results.Count == 0) + { + return $"No documentation results were found for '{query}'."; + } + + using var builder = ZString.CreateStringBuilder(); + builder.Append("Documentation results for '"); + builder.Append(query); + builder.AppendLine("':"); + + var index = 0; + + foreach (var result in results) + { + index++; + builder.AppendLine(); + builder.Append('['); + builder.Append(index); + builder.Append("] "); + builder.Append(result.Title); + builder.Append(" — "); + builder.Append(result.Url); + builder.AppendLine(); + + if (!string.IsNullOrWhiteSpace(result.Snippet)) + { + builder.AppendLine(result.Snippet); + } + } + + return builder.ToString(); + } + + private static bool TryConvertToInt32(object value, out int result) + { + switch (value) + { + case int intValue: + result = intValue; + + return true; + case long longValue: + result = (int)longValue; + + return true; + case string stringValue when int.TryParse(stringValue, out var parsed): + result = parsed; + + return true; + case JsonElement { ValueKind: JsonValueKind.Number } jsonElement when jsonElement.TryGetInt32(out var jsonInt): + result = jsonInt; + + return true; + case JsonElement { ValueKind: JsonValueKind.String } jsonElement when int.TryParse(jsonElement.GetString(), out var jsonParsed): + result = jsonParsed; + + return true; + default: + result = 0; + + return false; + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntry.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntry.cs deleted file mode 100644 index ff4087f8..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntry.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System.Text.Json.Serialization; -using CrestApps.Core.Models; -using CrestApps.Core.Services; - -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// A catalog entry that describes a documentation search source stored in a catalog (for example a -/// YesSql or EntityCore store) so sources can be created and managed through a UI or database in -/// addition to being registered in code. The value carries the -/// search strategy (see ), and the remaining properties hold -/// the union of settings used by the built-in strategies. -/// -public sealed class DocumentationSourceEntry : SourceCatalogEntry, INameAwareModel, IModifiedUtcAwareModel, ICloneable -{ - /// - /// Gets or sets the unique logical name of the source. A caller can scope a search to this name. - /// - public string Name { get; set; } - - /// - /// Gets or sets the search strategy identifier. This is an alias for - /// and must match a registered - /// (see ). - /// - [JsonIgnore] - public string Strategy - { - get => Source; - set => Source = value; - } - - /// - /// Gets or sets the human-readable display name of the source. - /// - public string DisplayText { get; set; } - - /// - /// Gets or sets the base URL of the documentation site. Used by the sitemap and search-index - /// strategies to resolve the sitemap, index, and result URLs. - /// - public string BaseUrl { get; set; } - - /// - /// Gets or sets an explicit sitemap URL for the sitemap strategy. When not set, the sitemap - /// strategy resolves it from . - /// - public string SitemapUrl { get; set; } - - /// - /// Gets or sets an explicit search index URL for the search-index strategy. When not set, the - /// search-index strategy resolves it from . - /// - public string IndexUrl { get; set; } - - /// - /// Gets or sets the Algolia application identifier for the Algolia strategy. - /// - public string ApplicationId { get; set; } - - /// - /// Gets or sets the Algolia search-only API key for the Algolia strategy. - /// - public string ApiKey { get; set; } - - /// - /// Gets or sets the Algolia index name for the Algolia strategy. - /// - public string IndexName { get; set; } - - /// - /// Gets or sets the maximum number of results this source contributes to a search. When not set, - /// the global value is used. - /// - public int? MaxResults { get; set; } - - /// - /// Gets or sets the maximum number of pages the sitemap strategy indexes. When not set, the global - /// value is used. - /// - public int? MaxPages { get; set; } - - /// - /// Gets or sets the UTC timestamp when this entry was created. - /// - public DateTime CreatedUtc { get; set; } - - /// - /// Gets or sets the UTC timestamp when this entry was last modified. - /// - public DateTime? ModifiedUtc { get; set; } - - /// - /// Gets or sets the identifier of the user who created this entry. - /// - public string Author { get; set; } - - /// - /// Gets or sets the owner identifier associated with this entry. - /// - public string OwnerId { get; set; } - - /// - /// Creates a deep copy of this entry. - /// - /// The cloned entry. - public DocumentationSourceEntry Clone() - { - return new DocumentationSourceEntry - { - ItemId = ItemId, - Source = Source, - Name = Name, - DisplayText = DisplayText, - BaseUrl = BaseUrl, - SitemapUrl = SitemapUrl, - IndexUrl = IndexUrl, - ApplicationId = ApplicationId, - ApiKey = ApiKey, - IndexName = IndexName, - MaxResults = MaxResults, - MaxPages = MaxPages, - CreatedUtc = CreatedUtc, - ModifiedUtc = ModifiedUtc, - Author = Author, - OwnerId = OwnerId, - Properties = Properties.Clone(), - }; - } -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntryCatalogHandler.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntryCatalogHandler.cs deleted file mode 100644 index 1a0b795e..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceEntryCatalogHandler.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Security.Claims; -using System.Text.Json.Nodes; -using CrestApps.Core.Handlers; -using CrestApps.Core.Models; -using CrestApps.Core.Support; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Localization; - -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// The authoritative catalog handler for . It populates entries -/// from JSON, sets create-time defaults, and validates that the strategy and its required fields are -/// present so store-backed sources can be created and edited safely through a UI or database. -/// -internal sealed class DocumentationSourceEntryCatalogHandler : CatalogEntryHandlerBase -{ - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly TimeProvider _timeProvider; - private readonly IDocumentationSourceCatalog _catalog; - private readonly HashSet _strategies; - - private readonly IStringLocalizer S; - - /// - /// Initializes a new instance of the class. - /// - /// The HTTP context accessor. - /// The time provider. - /// The documentation source catalog used to enforce unique names. - /// The registered strategy factories used to validate the strategy. - /// The string localizer. - public DocumentationSourceEntryCatalogHandler( - IHttpContextAccessor httpContextAccessor, - TimeProvider timeProvider, - IDocumentationSourceCatalog catalog, - IEnumerable factories, - IStringLocalizer stringLocalizer) - { - _httpContextAccessor = httpContextAccessor; - _timeProvider = timeProvider; - _catalog = catalog; - _strategies = new HashSet(factories.Select(factory => factory.Strategy), StringComparer.OrdinalIgnoreCase); - S = stringLocalizer; - } - - /// - public override Task InitializingAsync(InitializingContext context, CancellationToken cancellationToken = default) - => PopulateAsync(context.Model, context.Data, true); - - /// - public override async Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) - { - await PopulateAsync(context.Model, context.Data, false); - - context.Model.ModifiedUtc = _timeProvider.GetUtcNow().UtcDateTime; - } - - /// - public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) - { - EnsureCreatedDefaults(context.Model); - - return Task.CompletedTask; - } - - /// - public override Task CreatingAsync(CreatingContext context, CancellationToken cancellationToken = default) - { - EnsureCreatedDefaults(context.Model); - - return Task.CompletedTask; - } - - /// - public override async Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) - { - var model = context.Model; - - if (string.IsNullOrWhiteSpace(model.Name)) - { - context.Result.Fail(new ValidationResult(S["Name is required."], [nameof(DocumentationSourceEntry.Name)])); - } - - if (string.IsNullOrWhiteSpace(model.Source)) - { - context.Result.Fail(new ValidationResult(S["Strategy is required."], [nameof(DocumentationSourceEntry.Strategy)])); - } - else if (!_strategies.Contains(model.Source)) - { - context.Result.Fail(new ValidationResult(S["Unknown documentation search strategy '{0}'.", model.Source], [nameof(DocumentationSourceEntry.Strategy)])); - } - else - { - ValidateStrategyFields(context); - } - - await ValidateUniqueNameAsync(context, cancellationToken); - } - - private void ValidateStrategyFields(ValidatingContext context) - { - var model = context.Model; - - if (string.Equals(model.Source, DocumentationSourceStrategies.Algolia, StringComparison.OrdinalIgnoreCase)) - { - if (string.IsNullOrWhiteSpace(model.ApplicationId)) - { - context.Result.Fail(new ValidationResult(S["Application Id is required for the Algolia strategy."], [nameof(DocumentationSourceEntry.ApplicationId)])); - } - - if (string.IsNullOrWhiteSpace(model.ApiKey)) - { - context.Result.Fail(new ValidationResult(S["API Key is required for the Algolia strategy."], [nameof(DocumentationSourceEntry.ApiKey)])); - } - - if (string.IsNullOrWhiteSpace(model.IndexName)) - { - context.Result.Fail(new ValidationResult(S["Index Name is required for the Algolia strategy."], [nameof(DocumentationSourceEntry.IndexName)])); - } - - return; - } - - if (string.IsNullOrWhiteSpace(model.BaseUrl)) - { - context.Result.Fail(new ValidationResult(S["Base URL is required for the '{0}' strategy.", model.Source], [nameof(DocumentationSourceEntry.BaseUrl)])); - } - } - - private void EnsureCreatedDefaults(DocumentationSourceEntry entry) - { - if (entry.CreatedUtc == default) - { - entry.CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime; - } - - var user = _httpContextAccessor.HttpContext?.User; - - if (user is null) - { - return; - } - - entry.OwnerId ??= user.FindFirstValue(ClaimTypes.NameIdentifier); - entry.Author ??= user.Identity?.Name; - } - - private async Task ValidateUniqueNameAsync(ValidatingContext context, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(context.Model.Name)) - { - return; - } - - var existing = await _catalog.FindByNameAsync(context.Model.Name, cancellationToken); - - if (existing is not null && !string.Equals(existing.ItemId, context.Model.ItemId, StringComparison.Ordinal)) - { - context.Result.Fail(new ValidationResult(S["A documentation source with this name already exists. The name must be unique."], [nameof(DocumentationSourceEntry.Name)])); - } - } - - private static Task PopulateAsync(DocumentationSourceEntry entry, JsonNode data, bool isNew) - { - if (data is not JsonObject json) - { - return Task.CompletedTask; - } - - if (isNew) - { - json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.Name), value => entry.Name = value); - } - - if (!json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.Strategy), value => entry.Source = value)) - { - json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.Source), value => entry.Source = value); - } - - json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.DisplayText), value => entry.DisplayText = value); - json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.BaseUrl), value => entry.BaseUrl = value); - json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.SitemapUrl), value => entry.SitemapUrl = value); - json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.IndexUrl), value => entry.IndexUrl = value); - json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.ApplicationId), value => entry.ApplicationId = value); - json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.ApiKey), value => entry.ApiKey = value); - json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.IndexName), value => entry.IndexName = value); - json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.OwnerId), value => entry.OwnerId = value); - json.TryUpdateTrimmedStringValue(nameof(DocumentationSourceEntry.Author), value => entry.Author = value); - - if (json.TryGetNullableInt32Value(nameof(DocumentationSourceEntry.MaxResults), out var maxResults)) - { - entry.MaxResults = maxResults; - } - - if (json.TryGetNullableInt32Value(nameof(DocumentationSourceEntry.MaxPages), out var maxPages)) - { - entry.MaxPages = maxPages; - } - - if (json.TryGetDateTimeValue(nameof(DocumentationSourceEntry.CreatedUtc), out var createdUtc)) - { - entry.CreatedUtc = createdUtc; - } - - return Task.CompletedTask; - } -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceStrategies.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceStrategies.cs deleted file mode 100644 index 3bc571dd..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSourceStrategies.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// The well-known documentation search strategy identifiers. A strategy determines how a stored -/// is materialized into a runtime . -/// The value is stored in so the catalog can carry -/// heterogeneous strategies in a single store. Custom strategies can be added by registering an -/// with a new strategy identifier. -/// -public static class DocumentationSourceStrategies -{ - /// - /// The strategy that crawls a site's sitemap.xml and ranks pages locally. - /// - public const string Sitemap = "sitemap"; - - /// - /// The strategy that downloads a prebuilt JSON search index (for example a MkDocs Material - /// search_index.json) and ranks its entries locally. - /// - public const string SearchIndex = "search-index"; - - /// - /// The strategy that forwards queries to the hosted Algolia DocSearch query API. - /// - public const string Algolia = "algolia"; -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolConstants.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolConstants.cs new file mode 100644 index 00000000..4529530d --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolConstants.cs @@ -0,0 +1,31 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Well-known identifiers for the built-in documentation search tool instance sources. Each source is +/// registered under a unique name that is stored as the +/// of every created from it. +/// +public static class DocumentationToolConstants +{ + /// + /// The registered source name of the sitemap crawling documentation source (for example a public + /// Docusaurus site that exposes a sitemap.xml). + /// + public const string SitemapSourceName = "sitemap-documentation"; + + /// + /// The registered source name of the prebuilt JSON search index documentation source (for example a + /// MkDocs Material search_index.json). + /// + public const string SearchIndexSourceName = "search-index-documentation"; + + /// + /// The registered source name of the hosted Algolia DocSearch documentation source. + /// + public const string AlgoliaSourceName = "algolia-documentation"; + + /// + /// The category applied to the documentation search sources so they are grouped as knowledge-base tools. + /// + public const string Category = "Knowledgebase"; +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolInstanceServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolInstanceServiceCollectionExtensions.cs new file mode 100644 index 00000000..62e40ddd --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolInstanceServiceCollectionExtensions.cs @@ -0,0 +1,129 @@ +using CrestApps.Core.AI; +using CrestApps.Core.Builders; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Convenience registration for the built-in documentation search tool instance sources. Each source is a +/// developer-authored blueprint that users configure one or more times as +/// catalog entries, producing one callable documentation search function per configured site. The sources +/// are registered on the tool instances builder so they can be persisted and managed through a store or UI. +/// +public static class DocumentationToolInstanceServiceCollectionExtensions +{ + /// + /// Registers all built-in documentation search sources (sitemap crawling, prebuilt JSON search index, + /// and Algolia DocSearch) on the tool instances builder. + /// + /// The tool instances builder. + /// The tool instances builder, for chaining. + public static CrestAppsAIToolInstancesBuilder AddDocumentationSearchSources(this CrestAppsAIToolInstancesBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .AddSitemapDocumentationSource() + .AddSearchIndexDocumentationSource() + .AddAlgoliaDocumentationSource(); + + return builder; + } + + /// + /// Registers the sitemap crawling documentation search source on the tool instances builder so users + /// can create configured instances that search a public site through its sitemap.xml. + /// + /// The tool instances builder. + /// An optional delegate used to override the source display metadata. + /// The tool instances builder, for chaining. + public static CrestAppsAIToolInstancesBuilder AddSitemapDocumentationSource( + this CrestAppsAIToolInstancesBuilder builder, + Action configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + + AddSharedServices(builder); + + builder.AddSource(DocumentationToolConstants.SitemapSourceName, entry => + { + entry.DisplayName = new LocalizedString(DocumentationToolConstants.SitemapSourceName, "Documentation search (sitemap)"); + entry.Description = new LocalizedString( + DocumentationToolConstants.SitemapSourceName, + "Searches a documentation site by crawling its sitemap.xml (for example a public Docusaurus site)."); + entry.Category = new LocalizedString(DocumentationToolConstants.Category, DocumentationToolConstants.Category); + + configure?.Invoke(entry); + }); + + return builder; + } + + /// + /// Registers the prebuilt JSON search index documentation search source on the tool instances builder + /// so users can create configured instances that search a site publishing a search_index.json. + /// + /// The tool instances builder. + /// An optional delegate used to override the source display metadata. + /// The tool instances builder, for chaining. + public static CrestAppsAIToolInstancesBuilder AddSearchIndexDocumentationSource( + this CrestAppsAIToolInstancesBuilder builder, + Action configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + + AddSharedServices(builder); + + builder.AddSource(DocumentationToolConstants.SearchIndexSourceName, entry => + { + entry.DisplayName = new LocalizedString(DocumentationToolConstants.SearchIndexSourceName, "Documentation search (search index)"); + entry.Description = new LocalizedString( + DocumentationToolConstants.SearchIndexSourceName, + "Searches a documentation site that publishes a prebuilt JSON search index (for example MkDocs Material)."); + entry.Category = new LocalizedString(DocumentationToolConstants.Category, DocumentationToolConstants.Category); + + configure?.Invoke(entry); + }); + + return builder; + } + + /// + /// Registers the Algolia DocSearch documentation search source on the tool instances builder so users + /// can create configured instances that query the hosted Algolia DocSearch API. + /// + /// The tool instances builder. + /// An optional delegate used to override the source display metadata. + /// The tool instances builder, for chaining. + public static CrestAppsAIToolInstancesBuilder AddAlgoliaDocumentationSource( + this CrestAppsAIToolInstancesBuilder builder, + Action configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + + AddSharedServices(builder); + + builder.AddSource(DocumentationToolConstants.AlgoliaSourceName, entry => + { + entry.DisplayName = new LocalizedString(DocumentationToolConstants.AlgoliaSourceName, "Documentation search (Algolia DocSearch)"); + entry.Description = new LocalizedString( + DocumentationToolConstants.AlgoliaSourceName, + "Searches a documentation site through the hosted Algolia DocSearch query API."); + entry.Category = new LocalizedString(DocumentationToolConstants.Category, DocumentationToolConstants.Category); + + configure?.Invoke(entry); + }); + + return builder; + } + + private static void AddSharedServices(CrestAppsAIToolInstancesBuilder builder) + { + builder.Services.TryAddSingleton(TimeProvider.System); + builder.Services.TryAddSingleton(); + builder.Services.AddHttpClient(McpConstants.DocumentationHttpClientName) + .AddStandardResilienceHandler(); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceCatalog.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceCatalog.cs deleted file mode 100644 index 2ff412e0..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceCatalog.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CrestApps.Core.Services; - -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// A catalog of items. Implementations aggregate documentation -/// source entries from all registered catalog sources (for example a YesSql or EntityCore store) so the -/// documentation search tool can materialize sources defined in a database or through a UI in addition -/// to those registered in code. -/// -public interface IDocumentationSourceCatalog : INamedSourceCatalog -{ -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceFactory.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceFactory.cs deleted file mode 100644 index b0c1f343..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceFactory.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// Materializes a stored into a runtime -/// for a specific search strategy. Register an implementation to add -/// support for a new strategy that can be stored in the documentation source catalog. -/// -public interface IDocumentationSourceFactory -{ - /// - /// Gets the strategy identifier this factory handles. This is matched against - /// (see ). - /// - string Strategy { get; } - - /// - /// Creates a documentation source from the supplied entry. - /// - /// The stored source entry to materialize. - /// The runtime documentation source. - IDocumentationSource Create(DocumentationSourceEntry entry); -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceMaterializer.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceMaterializer.cs new file mode 100644 index 00000000..202e179b --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceMaterializer.cs @@ -0,0 +1,19 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// Caches the runtime materialized for each configured documentation +/// search tool instance so an expensive crawled corpus or downloaded index is reused across searches. +/// A cached source is rebuilt only when the instance that defines it changes. +/// +public interface IDocumentationSourceMaterializer +{ + /// + /// Gets the cached documentation source for the supplied key, creating it with + /// when it is missing or when the cached no longer matches. + /// + /// A stable key that identifies the defining instance (for example its item id). + /// A value that changes whenever the instance's settings change. + /// The factory used to build the source when it must be created or rebuilt. + /// The cached or newly created documentation source. + IDocumentationSource GetOrCreate(string key, string signature, Func factory); +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceProvider.cs deleted file mode 100644 index 9dddac9e..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceProvider.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// Resolves the complete set of documentation sources available to the documentation search tool. The -/// default implementation aggregates sources defined in options (through the documentation search -/// builder), sources stored in a catalog (for example a YesSql or EntityCore store), and custom -/// implementations registered in code. -/// -public interface IDocumentationSourceProvider -{ - /// - /// Gets all documentation sources that can be searched. - /// - /// - /// The request service provider used to resolve scoped services such as the documentation source - /// catalog and custom sources. - /// - /// The cancellation token. - /// The available documentation sources. - ValueTask> GetSourcesAsync(IServiceProvider services, CancellationToken cancellationToken = default); -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSourceFactory.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSourceFactory.cs deleted file mode 100644 index dbfd92a0..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSourceFactory.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Net.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// An that materializes a stored entry into a -/// . -/// -public sealed class SearchIndexDocumentationSourceFactory : IDocumentationSourceFactory -{ - private readonly DocumentationSearchOptions _options; - private readonly IHttpClientFactory _httpClientFactory; - private readonly TimeProvider _timeProvider; - private readonly ILoggerFactory _loggerFactory; - - /// - /// Initializes a new instance of the class. - /// - /// The documentation search options. - /// The HTTP client factory. - /// The time provider. - /// The logger factory. - public SearchIndexDocumentationSourceFactory( - IOptions options, - IHttpClientFactory httpClientFactory, - TimeProvider timeProvider, - ILoggerFactory loggerFactory) - { - _options = options.Value; - _httpClientFactory = httpClientFactory; - _timeProvider = timeProvider; - _loggerFactory = loggerFactory; - } - - /// - public string Strategy => DocumentationSourceStrategies.SearchIndex; - - /// - public IDocumentationSource Create(DocumentationSourceEntry entry) - { - ArgumentNullException.ThrowIfNull(entry); - - var site = new DocumentationSearchIndexSite - { - Name = entry.Name, - BaseUrl = entry.BaseUrl, - IndexUrl = entry.IndexUrl, - MaxResults = entry.MaxResults, - }; - - return new SearchIndexDocumentationSource( - site, - _options, - _httpClientFactory, - _timeProvider, - _loggerFactory.CreateLogger()); - } -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSettings.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSettings.cs new file mode 100644 index 00000000..ef213cc7 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSettings.cs @@ -0,0 +1,28 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// The user-provided settings for a prebuilt search index documentation search tool instance. The +/// settings are persisted in the owning +/// and bind the produced function to a single documentation site that publishes a JSON search index (for +/// example a MkDocs Material search_index.json). +/// +public sealed class SearchIndexDocumentationToolSettings +{ + /// + /// Gets or sets the base URL of the documentation site. It is used to resolve relative entry + /// locations and, when is not set, to derive the default index URL. + /// + public string BaseUrl { get; set; } + + /// + /// Gets or sets an explicit URL to the search index JSON. When not set, the source resolves it from + /// by appending /search/search_index.json. + /// + public string IndexUrl { get; set; } + + /// + /// Gets or sets the maximum number of results this instance returns for a single search. When not + /// set, a built-in default is used. + /// + public int? MaxResults { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSource.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSource.cs new file mode 100644 index 00000000..46d4e5d1 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSource.cs @@ -0,0 +1,57 @@ +using CrestApps.Core.AI.Tooling; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// The built-in that lets users configure documentation search over a +/// single site that publishes a prebuilt JSON search index (for example a MkDocs Material +/// search_index.json). Each configured binds one site; the AI model +/// only supplies the search query. +/// +public sealed class SearchIndexDocumentationToolSource : IAIToolInstanceSource +{ + /// + /// Creates the bound to the supplied instance's settings. + /// + /// The configured tool instance whose settings should be bound to the produced tool. + /// The configured documentation search function. + public AITool CreateTool(AIToolInstance instance) + { + ArgumentNullException.ThrowIfNull(instance); + + var settings = instance.TryGet(out var stored) + ? stored + : new SearchIndexDocumentationToolSettings(); + + var functionName = instance.GetFunctionName(); + var description = string.IsNullOrWhiteSpace(instance.Description) + ? "Searches the configured documentation site and returns the most relevant passages with their source URLs." + : instance.Description; + + return new DocumentationSearchToolFunction(functionName, description, instance, services => + { + var site = new DocumentationSearchIndexSite + { + Name = string.IsNullOrWhiteSpace(instance.Name) + ? functionName + : instance.Name, + BaseUrl = settings.BaseUrl, + IndexUrl = settings.IndexUrl, + MaxResults = settings.MaxResults, + }; + + var options = services.GetService>()?.Value ?? new DocumentationSearchOptions(); + var httpClientFactory = services.GetRequiredService(); + var timeProvider = services.GetService() ?? TimeProvider.System; + var logger = services.GetService()?.CreateLogger() + ?? NullLogger.Instance; + + return new SearchIndexDocumentationSource(site, options, httpClientFactory, timeProvider, logger); + }); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSourceFactory.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSourceFactory.cs deleted file mode 100644 index 53d6d2f6..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSourceFactory.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Net.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace CrestApps.Core.AI.Mcp.Documentation; - -/// -/// An that materializes a stored entry into a -/// . -/// -public sealed class SitemapDocumentationSourceFactory : IDocumentationSourceFactory -{ - private readonly DocumentationSearchOptions _options; - private readonly IHttpClientFactory _httpClientFactory; - private readonly TimeProvider _timeProvider; - private readonly ILoggerFactory _loggerFactory; - - /// - /// Initializes a new instance of the class. - /// - /// The documentation search options. - /// The HTTP client factory. - /// The time provider. - /// The logger factory. - public SitemapDocumentationSourceFactory( - IOptions options, - IHttpClientFactory httpClientFactory, - TimeProvider timeProvider, - ILoggerFactory loggerFactory) - { - _options = options.Value; - _httpClientFactory = httpClientFactory; - _timeProvider = timeProvider; - _loggerFactory = loggerFactory; - } - - /// - public string Strategy => DocumentationSourceStrategies.Sitemap; - - /// - public IDocumentationSource Create(DocumentationSourceEntry entry) - { - ArgumentNullException.ThrowIfNull(entry); - - var site = new DocumentationSite - { - Name = entry.Name, - BaseUrl = entry.BaseUrl, - SitemapUrl = entry.SitemapUrl, - MaxResults = entry.MaxResults, - MaxPages = entry.MaxPages, - }; - - return new SitemapDocumentationSource( - site, - _options, - _httpClientFactory, - _timeProvider, - _loggerFactory.CreateLogger()); - } -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSettings.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSettings.cs new file mode 100644 index 00000000..5d6ff5c0 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSettings.cs @@ -0,0 +1,33 @@ +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// The user-provided settings for a sitemap crawling documentation search tool instance. The settings +/// are persisted in the owning and bind +/// the produced function to a single documentation site whose pages are discovered through its +/// sitemap.xml. +/// +public sealed class SitemapDocumentationToolSettings +{ + /// + /// Gets or sets the base URL of the documentation site (for example https://core.crestapps.com). + /// + public string BaseUrl { get; set; } + + /// + /// Gets or sets an explicit sitemap URL. When not set, the crawler resolves the sitemap from + /// by appending /sitemap.xml. + /// + public string SitemapUrl { get; set; } + + /// + /// Gets or sets the maximum number of results this instance returns for a single search. When not + /// set, a built-in default is used. + /// + public int? MaxResults { get; set; } + + /// + /// Gets or sets the maximum number of pages the crawler indexes for this site. When not set, a + /// built-in default is used. + /// + public int? MaxPages { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSource.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSource.cs new file mode 100644 index 00000000..7da47d32 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSource.cs @@ -0,0 +1,57 @@ +using CrestApps.Core.AI.Tooling; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Mcp.Documentation; + +/// +/// The built-in that lets users configure documentation search over a +/// single site crawled through its sitemap.xml (for example a public Docusaurus site). Each +/// configured binds one site; the AI model only supplies the search query. +/// +public sealed class SitemapDocumentationToolSource : IAIToolInstanceSource +{ + /// + /// Creates the bound to the supplied instance's settings. + /// + /// The configured tool instance whose settings should be bound to the produced tool. + /// The configured documentation search function. + public AITool CreateTool(AIToolInstance instance) + { + ArgumentNullException.ThrowIfNull(instance); + + var settings = instance.TryGet(out var stored) + ? stored + : new SitemapDocumentationToolSettings(); + + var functionName = instance.GetFunctionName(); + var description = string.IsNullOrWhiteSpace(instance.Description) + ? "Searches the configured documentation site and returns the most relevant passages with their source URLs." + : instance.Description; + + return new DocumentationSearchToolFunction(functionName, description, instance, services => + { + var site = new DocumentationSite + { + Name = string.IsNullOrWhiteSpace(instance.Name) + ? functionName + : instance.Name, + BaseUrl = settings.BaseUrl, + SitemapUrl = settings.SitemapUrl, + MaxResults = settings.MaxResults, + MaxPages = settings.MaxPages, + }; + + var options = services.GetService>()?.Value ?? new DocumentationSearchOptions(); + var httpClientFactory = services.GetRequiredService(); + var timeProvider = services.GetService() ?? TimeProvider.System; + var logger = services.GetService()?.CreateLogger() + ?? NullLogger.Instance; + + return new SitemapDocumentationSource(site, options, httpClientFactory, timeProvider, logger); + }); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Functions/DocumentationSearchFunction.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/DocumentationSearchFunction.cs deleted file mode 100644 index f13c1f2e..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Functions/DocumentationSearchFunction.cs +++ /dev/null @@ -1,178 +0,0 @@ -using System.Text.Json; -using CrestApps.Core.AI.Extensions; -using CrestApps.Core.AI.Mcp.Documentation; -using Cysharp.Text; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace CrestApps.Core.AI.Mcp.Functions; - -/// -/// An AI tool that searches one or more configured documentation knowledge bases (for example public -/// Docusaurus or MkDocs sites) and returns the most relevant passages with source URLs. The tool is -/// opt-in; it is only registered when a host calls the documentation search registration extension. -/// -public sealed class DocumentationSearchFunction : AIFunction -{ - /// - /// The registered technical name of this tool. - /// - public const string TheName = "search_documentation"; - - /// - /// The tool category used to group documentation search with other knowledge-base tools. - /// - public const string Category = "knowledgebase"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "The search query used to find relevant documentation." - }, - "source": { - "type": "string", - "description": "Optional. The name of a single configured documentation source to search. When omitted, all sources are searched." - } - }, - "required": ["query"], - "additionalProperties": false - } - """); - - /// - /// Gets the name. - /// - public override string Name => TheName; - - /// - /// Gets the description. - /// - public override string Description => "Searches the configured documentation knowledge bases (such as Docusaurus or MkDocs sites) and returns the most relevant passages with their source URLs. Use this tool to answer questions from product or framework documentation."; - - /// - /// Gets the json Schema. - /// - public override JsonElement JsonSchema => _jsonSchema; - - /// - /// Gets the additional Properties. - /// - public override IReadOnlyDictionary AdditionalProperties { get; } = new Dictionary - { - ["Strict"] = false, - }; - - /// - /// Invokes the documentation search across the configured sources. - /// - /// The arguments. - /// The cancellation token. - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - var logger = arguments.Services.GetRequiredService>(); - - if (!arguments.TryGetFirstString("query", out var query) || string.IsNullOrWhiteSpace(query)) - { - logger.LogWarning("AI tool '{ToolName}' missing required argument 'query'.", Name); - - return "Unable to find a 'query' argument in the arguments parameter."; - } - - var provider = arguments.Services.GetRequiredService(); - var sources = await provider.GetSourcesAsync(arguments.Services, cancellationToken); - - arguments.TryGetFirstString("source", out var sourceName); - - if (!string.IsNullOrWhiteSpace(sourceName)) - { - sources = sources - .Where(source => string.Equals(source.Name, sourceName, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - if (sources.Count == 0) - { - return $"No documentation source named '{sourceName}' is configured."; - } - } - - if (sources.Count == 0) - { - return "No documentation sources are configured."; - } - - var request = new DocumentationSearchRequest(query); - - var searches = sources.Select(source => SearchSourceAsync(source, request, logger, cancellationToken)); - var resultsPerSource = await Task.WhenAll(searches); - - var results = resultsPerSource - .SelectMany(result => result) - .OrderByDescending(result => result.Score) - .ToList(); - - if (results.Count == 0) - { - return $"No documentation results were found for '{query}'."; - } - - using var builder = ZString.CreateStringBuilder(); - builder.Append("Documentation results for '"); - builder.Append(query); - builder.AppendLine("':"); - - var index = 0; - - foreach (var result in results) - { - index++; - builder.AppendLine(); - builder.Append('['); - builder.Append(index); - builder.Append("] "); - builder.Append(result.Title); - builder.Append(" — "); - builder.Append(result.Url); - - if (!string.IsNullOrWhiteSpace(result.SourceName)) - { - builder.Append(" (source: "); - builder.Append(result.SourceName); - builder.Append(')'); - } - - builder.AppendLine(); - - if (!string.IsNullOrWhiteSpace(result.Snippet)) - { - builder.AppendLine(result.Snippet); - } - } - - return builder.ToString(); - } - - private static async Task> SearchSourceAsync( - IDocumentationSource source, - DocumentationSearchRequest request, - ILogger logger, - CancellationToken cancellationToken) - { - try - { - var results = await source.SearchAsync(request, cancellationToken); - - return results ?? []; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Documentation source '{SourceName}' failed to search.", source.Name); - - return []; - } - } -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs index eba05925..bc2053ab 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs @@ -1,13 +1,14 @@ using CrestApps.Core.AI.Mcp.Services; using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Services; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using McpServerOptions = CrestApps.Core.AI.Mcp.Models.McpServerOptions; namespace CrestApps.Core.AI.Mcp; @@ -30,50 +31,21 @@ public static IMcpServerBuilder WithCrestAppsHandlers(this IMcpServerBuilder bui /// /// Registers the CrestApps MCP server handlers, letting the caller choose which capabilities - /// (tools, prompts, and resources) are exposed and which tools are listed and invokable. When - /// is every capability is registered and all - /// non-hidden tools are exposed, preserving the previous default behavior. + /// (tools, prompts, and resources) are registered. When is + /// every capability is registered. Which tools and tool instances are actually + /// listed and callable is controlled by the site settings allow-list. /// /// The builder. - /// A delegate that configures the exposed capabilities and tool filters. + /// A delegate that configures the registered capabilities. public static IMcpServerBuilder WithCrestAppsHandlers( this IMcpServerBuilder builder, Action configure) - { - return builder.WithCrestAppsHandlers(configuration: null, configure); - } - - /// - /// Registers the CrestApps MCP server handlers, binding the capability toggles from the supplied - /// configuration section so a host can enable or disable capabilities without code. Configuration - /// wins over code: any capability toggle explicitly set in - /// overrides the value chosen in . - /// - /// The builder. - /// - /// The configuration section bound to . When - /// no configuration binding is applied. - /// - /// A delegate that configures the exposed capabilities and tool filters. - public static IMcpServerBuilder WithCrestAppsHandlers( - this IMcpServerBuilder builder, - IConfiguration configuration, - Action configure) { ArgumentNullException.ThrowIfNull(builder); var handlerBuilder = new CrestAppsMcpHandlerBuilder(); configure?.Invoke(handlerBuilder); - if (configuration is not null) - { - var options = new McpServerHandlerOptions(); - configuration.Bind(options); - handlerBuilder.ApplyOptions(options); - - builder.Services.Configure(configuration); - } - return builder.WithCrestAppsHandlers(handlerBuilder); } @@ -85,20 +57,27 @@ private static IMcpServerBuilder WithCrestAppsHandlers( if (handlerBuilder.IncludeTools) { - var includeSdkTools = handlerBuilder.IncludeSdkTools; - builder - .WithListToolsHandler((request, cancellationToken) => + .WithListToolsHandler(async (request, cancellationToken) => { + var serverOptions = request.Services.GetRequiredService>().CurrentValue; + var exposeAll = serverOptions.ExposeAllTools; + var allowList = BuildAllowList(serverOptions.Tools); var toolDefinitions = request.Services.GetRequiredService>().Value; ILogger logger = null; var tools = new List(); + var seenNames = new HashSet(StringComparer.Ordinal); - foreach (var (name, _) in toolDefinitions.Tools.Where(tool => !tool.Value.Hidden && handlerBuilder.IsToolAllowed(tool.Key, tool.Value))) + foreach (var (name, definition) in toolDefinitions.Tools) { + if (definition.Hidden || !IsAllowed(exposeAll, allowList, name, definition.Name)) + { + continue; + } + try { - if (request.Services.GetKeyedService(name) is AIFunction aiFunction) + if (request.Services.GetKeyedService(name) is AIFunction aiFunction && seenNames.Add(aiFunction.Name)) { tools.Add(new Tool { @@ -115,70 +94,68 @@ private static IMcpServerBuilder WithCrestAppsHandlers( } } - if (includeSdkTools) + var instanceCatalog = request.Services.GetService>(); + + if (instanceCatalog is not null) { - var sdkTools = request.Services.GetService>(); + var instances = await instanceCatalog.GetAllAsync(cancellationToken); - if (sdkTools is not null) + foreach (var instance in instances) { - using var sdkToolEnumerator = sdkTools.GetEnumerator(); + if (string.IsNullOrEmpty(instance.Source)) + { + continue; + } + + var functionName = instance.GetFunctionName(); - if (sdkToolEnumerator.MoveNext()) + if (!IsAllowed(exposeAll, allowList, functionName, instance.Name)) { - var toolNames = new HashSet(tools.Count, StringComparer.Ordinal); + continue; + } - foreach (var tool in tools) - { - toolNames.Add(tool.Name); - } + var source = request.Services.GetKeyedService(instance.Source); - do - { - var sdkTool = sdkToolEnumerator.Current; + if (source is null) + { + continue; + } - if (toolNames.Add(sdkTool.ProtocolTool.Name)) + try + { + if (source.CreateTool(instance) is AIFunction aiFunction && seenNames.Add(aiFunction.Name)) + { + tools.Add(new Tool { - tools.Add(sdkTool.ProtocolTool); - } + Name = aiFunction.Name, + Description = aiFunction.Description, + InputSchema = aiFunction.JsonSchema, + }); } - while (sdkToolEnumerator.MoveNext()); + } + catch (Exception ex) + { + logger ??= request.Services.GetRequiredService>(); + logger.LogError(ex, "Error creating tool for instance '{InstanceName}'.", instance.Name); } } } - return ValueTask.FromResult(new ListToolsResult { Tools = tools }); + return new ListToolsResult { Tools = tools }; }) .WithCallToolHandler(async (request, cancellationToken) => { + var serverOptions = request.Services.GetRequiredService>().CurrentValue; + var exposeAll = serverOptions.ExposeAllTools; + var allowList = BuildAllowList(serverOptions.Tools); var toolDefinitions = request.Services.GetRequiredService>().Value; - if (toolDefinitions.Tools.TryGetValue(request.Params.Name, out var definition) && - !definition.Hidden && - handlerBuilder.IsToolAllowed(request.Params.Name, definition)) - { - if (request.Services.GetKeyedService(request.Params.Name) is not AIFunction aiFunction) - { - throw new McpException($"Failed to create tool '{request.Params.Name}'."); - } - - var arguments = new AIFunctionArguments - { - Services = request.Services, - Context = new Dictionary - { - ["mcpRequest"] = request, - }, - }; - - if (request.Params.Arguments is not null) - { - foreach (var kvp in request.Params.Arguments) - { - arguments[kvp.Key] = kvp.Value; - } - } + var logger = request.Services.GetService>(); + var codeTool = ResolveAllowedCodeTool(request.Services, toolDefinitions, exposeAll, allowList, request.Params.Name, logger); - var result = await aiFunction.InvokeAsync(arguments, cancellationToken); + if (codeTool is not null) + { + var result = await codeTool.InvokeAsync(BuildArguments(request), cancellationToken); return new CallToolResult { @@ -186,14 +163,27 @@ private static IMcpServerBuilder WithCrestAppsHandlers( }; } - if (includeSdkTools) + var instanceCatalog = request.Services.GetService>(); + + if (instanceCatalog is not null) { - var sdkTools = request.Services.GetService>(); - var sdkTool = sdkTools?.FirstOrDefault(t => t.ProtocolTool.Name == request.Params.Name); + var instance = await ResolveInstanceAsync(instanceCatalog, request.Params.Name, cancellationToken); - if (sdkTool is not null) + if (instance is not null && + !string.IsNullOrEmpty(instance.Source) && + IsAllowed(exposeAll, allowList, instance.GetFunctionName(), instance.Name)) { - return await sdkTool.InvokeAsync(request, cancellationToken); + var source = request.Services.GetKeyedService(instance.Source); + + if (source is not null && source.CreateTool(instance) is AIFunction instanceFunction) + { + var result = await instanceFunction.InvokeAsync(BuildArguments(request), cancellationToken); + + return new CallToolResult + { + Content = [new TextContentBlock { Text = result?.ToString() ?? string.Empty }], + }; + } } } @@ -252,4 +242,129 @@ private static IMcpServerBuilder WithCrestAppsHandlers( return builder; } + + private static HashSet BuildAllowList(IEnumerable names) + { + var allowList = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (names is not null) + { + foreach (var name in names) + { + if (!string.IsNullOrWhiteSpace(name)) + { + allowList.Add(name.Trim()); + } + } + } + + return allowList; + } + + private static bool IsAllowed(bool exposeAll, HashSet allowList, params string[] candidates) + { + if (exposeAll) + { + return true; + } + + foreach (var candidate in candidates) + { + if (!string.IsNullOrEmpty(candidate) && allowList.Contains(candidate)) + { + return true; + } + } + + return false; + } + + private static AIFunction ResolveAllowedCodeTool( + IServiceProvider services, + AIToolDefinitionOptions toolDefinitions, + bool exposeAll, + HashSet allowList, + string protocolName, + ILogger logger) + { + if (toolDefinitions.Tools.TryGetValue(protocolName, out var direct) && + !direct.Hidden && + IsAllowed(exposeAll, allowList, protocolName, direct.Name) && + TryCreateFunction(services, protocolName, logger) is { } directFunction && + string.Equals(directFunction.Name, protocolName, StringComparison.Ordinal)) + { + return directFunction; + } + + foreach (var (name, definition) in toolDefinitions.Tools) + { + if (definition.Hidden || !IsAllowed(exposeAll, allowList, name, definition.Name)) + { + continue; + } + + if (TryCreateFunction(services, name, logger) is { } function && + string.Equals(function.Name, protocolName, StringComparison.Ordinal)) + { + return function; + } + } + + return null; + } + + private static AIFunction TryCreateFunction(IServiceProvider services, string key, ILogger logger) + { + try + { + return services.GetKeyedService(key) as AIFunction; + } + catch (Exception ex) + { + logger?.LogError(ex, "Error creating tool '{ToolName}'.", key); + + return null; + } + } + + private static async Task ResolveInstanceAsync( + INamedCatalog catalog, + string name, + CancellationToken cancellationToken) + { + var instances = await catalog.GetAllAsync(cancellationToken); + + foreach (var instance in instances) + { + if (string.Equals(instance.GetFunctionName(), name, StringComparison.Ordinal) || + (instance.Name is not null && string.Equals(instance.Name, name, StringComparison.OrdinalIgnoreCase))) + { + return instance; + } + } + + return null; + } + + private static AIFunctionArguments BuildArguments(RequestContext request) + { + var arguments = new AIFunctionArguments + { + Services = request.Services, + Context = new Dictionary + { + ["mcpRequest"] = request, + }, + }; + + if (request.Params.Arguments is not null) + { + foreach (var kvp in request.Params.Arguments) + { + arguments[kvp.Key] = kvp.Value; + } + } + + return arguments; + } } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerHandlerOptions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerHandlerOptions.cs deleted file mode 100644 index 1d038156..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerHandlerOptions.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace CrestApps.Core.AI.Mcp; - -/// -/// Strongly-typed options that control which CrestApps MCP server capabilities are exposed. Bind this -/// type from configuration (for example an Mcp:Server:Handlers section) so a host can enable or -/// disable capabilities without code. Each toggle is nullable: a value means the -/// setting is not configured and the value chosen in code is used instead. -/// -public sealed class McpServerHandlerOptions -{ - /// - /// Gets or sets a value indicating whether the tool list and call handlers are registered. When - /// the value configured in code is used. - /// - public bool? IncludeTools { get; set; } - - /// - /// Gets or sets a value indicating whether SDK tool instances registered in the service provider - /// are merged into the exposed tool set. This only applies when resolves - /// to . When the value configured in code is used. - /// - public bool? IncludeSdkTools { get; set; } - - /// - /// Gets or sets a value indicating whether the prompt list and get handlers are registered. When - /// the value configured in code is used. - /// - public bool? IncludePrompts { get; set; } - - /// - /// Gets or sets a value indicating whether the resource list, template list, and read handlers are - /// registered. When the value configured in code is used. - /// - public bool? IncludeResources { get; set; } -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpServerOptions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpServerOptions.cs index 6d6976ef..b9c5a6d0 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpServerOptions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpServerOptions.cs @@ -27,4 +27,20 @@ public sealed class McpServerOptions /// . /// public bool RequireAccessPermission { get; set; } = true; + + /// + /// Gets or sets a value indicating whether every non-hidden tool and configured tool instance is + /// exposed to MCP clients. When false (the default), the server exposes nothing unless a tool + /// or tool instance is explicitly listed in . When true, + /// is ignored and all non-hidden tools and instances are exposed. + /// + public bool ExposeAllTools { get; set; } + + /// + /// Gets or sets the allow-list of tool names exposed to MCP clients when + /// is false. Each entry matches a code-registered tool name, a tool instance's function name, or + /// a tool instance's technical name. The server exposes nothing by default, so a tool or instance is + /// only listed and callable when it appears here. + /// + public IList Tools { get; set; } = []; } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs index ba469610..ace9228d 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs @@ -223,73 +223,4 @@ public static IServiceCollection AddCoreAIMcpResourceType( return services; } - - /// - /// Registers the opt-in documentation search tool together with the built-in documentation source - /// provider. The tool is not registered by any default AI registration; call this method to expose - /// it, then configure the documentation sites and custom sources it may scan. - /// - /// The service collection. - /// An optional action used to configure documentation sources. - /// The service collection for chaining. - public static IServiceCollection AddCoreAIDocumentationSearch( - this IServiceCollection services, - Action configure = null) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddOptions(); - services.TryAddSingleton(TimeProvider.System); - services.AddHttpClient(McpConstants.DocumentationHttpClientName) - .AddStandardResilienceHandler(); - services.TryAddSingleton(); - - // Register the strategy factories used to materialize options-defined and store-backed entries. - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - - // Register the multi-source catalog so documentation sources can be persisted in a store and - // managed through a UI or database. The catalog is empty until a store backend adds a binding source. - services.TryAddScoped(); - services.TryAddScoped>(sp => sp.GetRequiredService()); - - services.TryAddScoped>(); - services.TryAddScoped>(sp => sp.GetRequiredService>()); - services.TryAddScoped>(sp => sp.GetRequiredService>()); - - services.TryAddEnumerable(ServiceDescriptor.Scoped, DocumentationSourceEntryCatalogHandler>()); - - if (!services.Any(descriptor => descriptor.ServiceType == typeof(DocumentationSearchFunction))) - { - services.AddCoreAITool(DocumentationSearchFunction.TheName) - .WithCategory(DocumentationSearchFunction.Category) - .WithPurpose(AIToolPurposes.DataSourceSearch) - .WithTitle("Search documentation") - .WithDescription("Searches configured documentation knowledge bases and returns relevant passages with source URLs."); - } - - configure?.Invoke(new DocumentationSearchBuilder(services)); - - return services; - } - - /// - /// Registers the opt-in documentation search tool on an MCP server builder. This is a convenience - /// wrapper over - /// so a knowledge-base MCP server can expose documentation search alongside its other capabilities. - /// - /// The MCP server builder. - /// An optional action used to configure documentation sources. - /// The MCP server builder for chaining. - public static CrestAppsMcpServerBuilder AddDocumentationSearch( - this CrestAppsMcpServerBuilder builder, - Action configure = null) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.Services.AddCoreAIDocumentationSearch(configure); - - return builder; - } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Admin/Settings/Index.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Admin/Settings/Index.razor index 76a0970c..69904146 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Admin/Settings/Index.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Admin/Settings/Index.razor @@ -724,6 +724,21 @@ else + +
+

+ Choose which tools and configured tool instances are exposed to MCP clients. Nothing is + exposed by default; list the tools to allow, or expose everything. +

+
+ + +
+
+ + +
One name per line (or comma-separated). Ignored when all tools are exposed.
+
@@ -870,6 +885,10 @@ else McpServerAuthenticationType = mcpServerSettings.AuthenticationType, McpServerApiKey = mcpServerSettings.ApiKey, McpServerRequireAccessPermission = mcpServerSettings.RequireAccessPermission, + McpServerExposeAllTools = mcpServerSettings.ExposeAllTools, + McpServerExposedTools = mcpServerSettings.Tools is null + ? string.Empty + : string.Join(Environment.NewLine, mcpServerSettings.Tools), CopilotAuthenticationType = copilotSettings.AuthenticationType, CopilotClientId = copilotSettings.ClientId, @@ -1182,6 +1201,13 @@ else AuthenticationType = _model.McpServerAuthenticationType, ApiKey = _model.McpServerApiKey?.Trim(), RequireAccessPermission = _model.McpServerRequireAccessPermission, + ExposeAllTools = _model.McpServerExposeAllTools, + Tools = string.IsNullOrWhiteSpace(_model.McpServerExposedTools) + ? [] + : _model.McpServerExposedTools + .Split(['\r', '\n', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(), }); SiteSettings.Set(new MemoryMetadata diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor index bb47e254..07b07491 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor @@ -5,6 +5,7 @@ @using CrestApps.Core.AI @using CrestApps.Core.AI.Tooling @using CrestApps.Core.AI.Tooling.Instances +@using CrestApps.Core.AI.Mcp.Documentation @using CrestApps.Core.Blazor.Web.ViewModels @using CrestApps.Core.Services @using Microsoft.Extensions.Options @@ -203,6 +204,87 @@ } + @if (string.Equals(_model.Source, DocumentationToolConstants.SitemapSourceName, StringComparison.OrdinalIgnoreCase)) + { +
Documentation site (sitemap crawl)
+ +
+ + +
The root URL of the documentation site to crawl.
+ +
+ +
+ + +
Optional. When empty, /sitemap.xml is appended to the base URL.
+
+ +
+
+ + +
+
+ + +
+
+ } + + @if (string.Equals(_model.Source, DocumentationToolConstants.SearchIndexSourceName, StringComparison.OrdinalIgnoreCase)) + { +
Documentation site (prebuilt search index)
+ +
+ + +
The root URL of the documentation site. Used to resolve relative entry locations.
+ +
+ +
+ + +
Optional. When empty, /search/search_index.json is appended to the base URL.
+
+ +
+ + +
+ } + + @if (string.Equals(_model.Source, DocumentationToolConstants.AlgoliaSourceName, StringComparison.OrdinalIgnoreCase)) + { +
Documentation site (Algolia DocSearch)
+ +
+ + + +
+ +
+ + +
Use the public, search-only key. It is safe to expose to clients and is stored without additional protection.
+ +
+ +
+ + + +
+ +
+ + +
+ } +
Cancel @@ -272,6 +354,42 @@ _errors.Add("A description is required so the AI model can tell instances apart."); } + if (string.Equals(model.Source, DocumentationToolConstants.SitemapSourceName, StringComparison.OrdinalIgnoreCase)) + { + ValidateAbsoluteUrl(model.SitemapBaseUrl, "Base URL", required: true); + ValidateAbsoluteUrl(model.SitemapUrl, "Sitemap URL", required: false); + + return; + } + + if (string.Equals(model.Source, DocumentationToolConstants.SearchIndexSourceName, StringComparison.OrdinalIgnoreCase)) + { + ValidateAbsoluteUrl(model.SearchIndexBaseUrl, "Base URL", required: true); + ValidateAbsoluteUrl(model.SearchIndexUrl, "Search index URL", required: false); + + return; + } + + if (string.Equals(model.Source, DocumentationToolConstants.AlgoliaSourceName, StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(model.AlgoliaApplicationId)) + { + _errors.Add("Application ID is required."); + } + + if (string.IsNullOrWhiteSpace(model.AlgoliaApiKey)) + { + _errors.Add("Search-only API key is required."); + } + + if (string.IsNullOrWhiteSpace(model.AlgoliaIndexName)) + { + _errors.Add("Index name is required."); + } + + return; + } + if (!string.Equals(model.Source, HttpApiRequestToolConstants.SourceName, StringComparison.OrdinalIgnoreCase)) { return; @@ -381,12 +499,68 @@ string.Equals(entry.Name, name, StringComparison.OrdinalIgnoreCase)); } + private void ValidateAbsoluteUrl(string value, string label, bool required) + { + if (string.IsNullOrWhiteSpace(value)) + { + if (required) + { + _errors.Add($"{label} is required."); + } + + return; + } + + if (!Uri.TryCreate(value, UriKind.Absolute, out _)) + { + _errors.Add($"{label} must be a valid absolute URL."); + } + } + private void Apply(AIToolInstanceViewModel model, AIToolInstance instance) { instance.Name = model.Name.Trim(); instance.Description = model.Description.Trim(); instance.ModifiedUtc = TimeProvider.GetUtcNow().UtcDateTime; + if (string.Equals(model.Source, DocumentationToolConstants.SitemapSourceName, StringComparison.OrdinalIgnoreCase)) + { + instance.Put(new SitemapDocumentationToolSettings + { + BaseUrl = model.SitemapBaseUrl?.Trim(), + SitemapUrl = string.IsNullOrWhiteSpace(model.SitemapUrl) ? null : model.SitemapUrl.Trim(), + MaxResults = model.SitemapMaxResults, + MaxPages = model.SitemapMaxPages, + }); + + return; + } + + if (string.Equals(model.Source, DocumentationToolConstants.SearchIndexSourceName, StringComparison.OrdinalIgnoreCase)) + { + instance.Put(new SearchIndexDocumentationToolSettings + { + BaseUrl = model.SearchIndexBaseUrl?.Trim(), + IndexUrl = string.IsNullOrWhiteSpace(model.SearchIndexUrl) ? null : model.SearchIndexUrl.Trim(), + MaxResults = model.SearchIndexMaxResults, + }); + + return; + } + + if (string.Equals(model.Source, DocumentationToolConstants.AlgoliaSourceName, StringComparison.OrdinalIgnoreCase)) + { + instance.Put(new AlgoliaDocumentationToolSettings + { + ApplicationId = model.AlgoliaApplicationId?.Trim(), + ApiKey = model.AlgoliaApiKey?.Trim(), + IndexName = model.AlgoliaIndexName?.Trim(), + MaxResults = model.AlgoliaMaxResults, + }); + + return; + } + var protector = DataProtectionProvider.CreateProtector(HttpApiRequestToolConstants.DataProtectionPurpose); var existing = instance.TryGet(out var stored) ? stored : new HttpApiRequestToolSettings(); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor index df04ccf1..56257364 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor @@ -4,6 +4,7 @@ @using CrestApps.Core.AI @using CrestApps.Core.AI.Tooling @using CrestApps.Core.AI.Tooling.Instances +@using CrestApps.Core.AI.Mcp.Documentation @using CrestApps.Core.Blazor.Web.ViewModels @using CrestApps.Core.Services @using Microsoft.Extensions.Options @@ -11,6 +12,7 @@ @inject IDataProtectionProvider DataProtectionProvider @inject IStoreCommitter StoreCommitter @inject NavigationManager Navigation +@inject TimeProvider TimeProvider @inject IOptions AIOptions Edit Tool Instance @@ -224,6 +226,87 @@ else
} + @if (string.Equals(_model.Source, DocumentationToolConstants.SitemapSourceName, StringComparison.OrdinalIgnoreCase)) + { +
Documentation site (sitemap crawl)
+ +
+ + +
The root URL of the documentation site to crawl.
+ +
+ +
+ + +
Optional. When empty, /sitemap.xml is appended to the base URL.
+
+ +
+
+ + +
+
+ + +
+
+ } + + @if (string.Equals(_model.Source, DocumentationToolConstants.SearchIndexSourceName, StringComparison.OrdinalIgnoreCase)) + { +
Documentation site (prebuilt search index)
+ +
+ + +
The root URL of the documentation site. Used to resolve relative entry locations.
+ +
+ +
+ + +
Optional. When empty, /search/search_index.json is appended to the base URL.
+
+ +
+ + +
+ } + + @if (string.Equals(_model.Source, DocumentationToolConstants.AlgoliaSourceName, StringComparison.OrdinalIgnoreCase)) + { +
Documentation site (Algolia DocSearch)
+ +
+ + + +
+ +
+ + +
Use the public, search-only key. It is safe to expose to clients and is stored without additional protection.
+ +
+ +
+ + + +
+ +
+ + +
+ } +
Cancel @@ -304,6 +387,42 @@ else _errors.Add("A description is required so the AI model can tell instances apart."); } + if (string.Equals(model.Source, DocumentationToolConstants.SitemapSourceName, StringComparison.OrdinalIgnoreCase)) + { + ValidateAbsoluteUrl(model.SitemapBaseUrl, "Base URL", required: true); + ValidateAbsoluteUrl(model.SitemapUrl, "Sitemap URL", required: false); + + return; + } + + if (string.Equals(model.Source, DocumentationToolConstants.SearchIndexSourceName, StringComparison.OrdinalIgnoreCase)) + { + ValidateAbsoluteUrl(model.SearchIndexBaseUrl, "Base URL", required: true); + ValidateAbsoluteUrl(model.SearchIndexUrl, "Search index URL", required: false); + + return; + } + + if (string.Equals(model.Source, DocumentationToolConstants.AlgoliaSourceName, StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(model.AlgoliaApplicationId)) + { + _errors.Add("Application ID is required."); + } + + if (string.IsNullOrWhiteSpace(model.AlgoliaApiKey)) + { + _errors.Add("Search-only API key is required."); + } + + if (string.IsNullOrWhiteSpace(model.AlgoliaIndexName)) + { + _errors.Add("Index name is required."); + } + + return; + } + if (string.IsNullOrWhiteSpace(model.BaseUrl)) { _errors.Add("Base URL is required."); @@ -402,6 +521,45 @@ else private void Apply(AIToolInstanceViewModel model, AIToolInstance instance) { instance.Description = model.Description.Trim(); + instance.ModifiedUtc = TimeProvider.GetUtcNow().UtcDateTime; + + if (string.Equals(model.Source, DocumentationToolConstants.SitemapSourceName, StringComparison.OrdinalIgnoreCase)) + { + instance.Put(new SitemapDocumentationToolSettings + { + BaseUrl = model.SitemapBaseUrl?.Trim(), + SitemapUrl = string.IsNullOrWhiteSpace(model.SitemapUrl) ? null : model.SitemapUrl.Trim(), + MaxResults = model.SitemapMaxResults, + MaxPages = model.SitemapMaxPages, + }); + + return; + } + + if (string.Equals(model.Source, DocumentationToolConstants.SearchIndexSourceName, StringComparison.OrdinalIgnoreCase)) + { + instance.Put(new SearchIndexDocumentationToolSettings + { + BaseUrl = model.SearchIndexBaseUrl?.Trim(), + IndexUrl = string.IsNullOrWhiteSpace(model.SearchIndexUrl) ? null : model.SearchIndexUrl.Trim(), + MaxResults = model.SearchIndexMaxResults, + }); + + return; + } + + if (string.Equals(model.Source, DocumentationToolConstants.AlgoliaSourceName, StringComparison.OrdinalIgnoreCase)) + { + instance.Put(new AlgoliaDocumentationToolSettings + { + ApplicationId = model.AlgoliaApplicationId?.Trim(), + ApiKey = model.AlgoliaApiKey?.Trim(), + IndexName = model.AlgoliaIndexName?.Trim(), + MaxResults = model.AlgoliaMaxResults, + }); + + return; + } var protector = DataProtectionProvider.CreateProtector(HttpApiRequestToolConstants.DataProtectionPurpose); var existing = instance.TryGet(out var stored) ? stored : new HttpApiRequestToolSettings(); @@ -485,6 +643,47 @@ else : "{}"; } + if (instance.TryGet(out var sitemapSettings)) + { + model.SitemapBaseUrl = sitemapSettings.BaseUrl; + model.SitemapUrl = sitemapSettings.SitemapUrl; + model.SitemapMaxResults = sitemapSettings.MaxResults; + model.SitemapMaxPages = sitemapSettings.MaxPages; + } + + if (instance.TryGet(out var searchIndexSettings)) + { + model.SearchIndexBaseUrl = searchIndexSettings.BaseUrl; + model.SearchIndexUrl = searchIndexSettings.IndexUrl; + model.SearchIndexMaxResults = searchIndexSettings.MaxResults; + } + + if (instance.TryGet(out var algoliaSettings)) + { + model.AlgoliaApplicationId = algoliaSettings.ApplicationId; + model.AlgoliaApiKey = algoliaSettings.ApiKey; + model.AlgoliaIndexName = algoliaSettings.IndexName; + model.AlgoliaMaxResults = algoliaSettings.MaxResults; + } + return model; } + + private void ValidateAbsoluteUrl(string value, string label, bool required) + { + if (string.IsNullOrWhiteSpace(value)) + { + if (required) + { + _errors.Add($"{label} is required."); + } + + return; + } + + if (!Uri.TryCreate(value, UriKind.Absolute, out _)) + { + _errors.Add($"{label} must be a valid absolute URL."); + } + } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Program.cs b/src/Startup/CrestApps.Core.Blazor.Web/Program.cs index 10a75d16..065cb094 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Program.cs @@ -13,6 +13,7 @@ using CrestApps.Core.AI.Elasticsearch; using CrestApps.Core.AI.Markdown; using CrestApps.Core.AI.Mcp; +using CrestApps.Core.AI.Mcp.Documentation; using CrestApps.Core.AI.Mcp.Ftp; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Mcp.Sftp; @@ -104,6 +105,7 @@ ) .AddToolInstances(toolInstances => toolInstances .AddHttpApiRequestSource() + .AddDocumentationSearchSources() .AddEntityCoreStores() ) .AddDocumentProcessing(documentProcessing => documentProcessing diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs index 921ecb11..68876dd3 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs @@ -134,4 +134,62 @@ public sealed class AIToolInstanceViewModel /// Gets or sets an optional per-request timeout in seconds. /// public int? TimeoutSeconds { get; set; } + + /// + /// Gets or sets the base URL of the documentation site for the sitemap crawling source. + /// + public string SitemapBaseUrl { get; set; } + + /// + /// Gets or sets an explicit sitemap URL for the sitemap crawling source. When empty, the crawler + /// resolves the sitemap from the base URL by appending /sitemap.xml. + /// + public string SitemapUrl { get; set; } + + /// + /// Gets or sets the maximum number of results the sitemap source returns for a single search. + /// + public int? SitemapMaxResults { get; set; } + + /// + /// Gets or sets the maximum number of pages the sitemap crawler indexes for the site. + /// + public int? SitemapMaxPages { get; set; } + + /// + /// Gets or sets the base URL of the documentation site for the prebuilt search index source. + /// + public string SearchIndexBaseUrl { get; set; } + + /// + /// Gets or sets an explicit URL to the search index JSON for the prebuilt search index source. When + /// empty, the source resolves it from the base URL by appending /search/search_index.json. + /// + public string SearchIndexUrl { get; set; } + + /// + /// Gets or sets the maximum number of results the prebuilt search index source returns for a single search. + /// + public int? SearchIndexMaxResults { get; set; } + + /// + /// Gets or sets the Algolia application identifier for the Algolia DocSearch source. + /// + public string AlgoliaApplicationId { get; set; } + + /// + /// Gets or sets the Algolia search-only API key for the Algolia DocSearch source. This is a public, + /// client-safe key and is stored without additional protection. + /// + public string AlgoliaApiKey { get; set; } + + /// + /// Gets or sets the Algolia index name to query for the Algolia DocSearch source. + /// + public string AlgoliaIndexName { get; set; } + + /// + /// Gets or sets the maximum number of results the Algolia DocSearch source returns for a single search. + /// + public int? AlgoliaMaxResults { get; set; } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/SettingsViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/SettingsViewModel.cs index 880c4353..42fad678 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/SettingsViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/SettingsViewModel.cs @@ -51,6 +51,10 @@ public sealed class SettingsViewModel public bool McpServerRequireAccessPermission { get; set; } = true; + public bool McpServerExposeAllTools { get; set; } + + public string McpServerExposedTools { get; set; } + // Default deployment settings. public string DefaultChatDeploymentName { get; set; } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs index 0a22662a..4f9d2c9b 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs @@ -111,6 +111,10 @@ public async Task Index() McpServerAuthenticationType = mcpServerSettings.AuthenticationType, McpServerApiKey = mcpServerSettings.ApiKey, McpServerRequireAccessPermission = mcpServerSettings.RequireAccessPermission, + McpServerExposeAllTools = mcpServerSettings.ExposeAllTools, + McpServerExposedTools = mcpServerSettings.Tools is null + ? string.Empty + : string.Join(Environment.NewLine, mcpServerSettings.Tools), CopilotAuthenticationType = copilotSettings.AuthenticationType, CopilotClientId = copilotSettings.ClientId, CopilotHasSecret = !string.IsNullOrWhiteSpace(copilotSettings.ProtectedClientSecret), @@ -338,6 +342,13 @@ public async Task Save(SettingsViewModel model) AuthenticationType = model.McpServerAuthenticationType, ApiKey = model.McpServerApiKey?.Trim(), RequireAccessPermission = model.McpServerRequireAccessPermission, + ExposeAllTools = model.McpServerExposeAllTools, + Tools = string.IsNullOrWhiteSpace(model.McpServerExposedTools) + ? [] + : model.McpServerExposedTools + .Split(['\r', '\n', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(), }); _siteSettings.Set(new MemoryMetadata diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs index 8d5b8c16..893ad7c3 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs @@ -56,6 +56,10 @@ public sealed class SettingsViewModel public bool McpServerRequireAccessPermission { get; set; } = true; + public bool McpServerExposeAllTools { get; set; } + + public string McpServerExposedTools { get; set; } + // Default deployment settings. public string DefaultChatDeploymentName { get; set; } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml index a3caf9b0..fbe23743 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml @@ -623,6 +623,20 @@
+
+

+ Choose which tools and configured tool instances are exposed to MCP clients. Nothing is + exposed by default; list the tools to allow, or expose everything. +

+
+ + +
+
+ + +
One name per line (or comma-separated). Ignored when all tools are exposed.
+
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs index 758788ee..af34dedf 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs @@ -1,5 +1,6 @@ using System.Text.Json; using CrestApps.Core.AI; +using CrestApps.Core.AI.Mcp.Documentation; using CrestApps.Core.AI.Tooling; using CrestApps.Core.AI.Tooling.Instances; using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels; @@ -214,6 +215,27 @@ private async Task ValidateAsync(AIToolInstanceViewModel model, bool isEditing) ModelState.AddModelError(nameof(model.Description), "A description is required so the AI model can tell instances apart."); } + if (string.Equals(model.Source, DocumentationToolConstants.SitemapSourceName, StringComparison.OrdinalIgnoreCase)) + { + ValidateSitemap(model); + + return; + } + + if (string.Equals(model.Source, DocumentationToolConstants.SearchIndexSourceName, StringComparison.OrdinalIgnoreCase)) + { + ValidateSearchIndex(model); + + return; + } + + if (string.Equals(model.Source, DocumentationToolConstants.AlgoliaSourceName, StringComparison.OrdinalIgnoreCase)) + { + ValidateAlgolia(model); + + return; + } + if (!string.Equals(model.Source, HttpApiRequestToolConstants.SourceName, StringComparison.OrdinalIgnoreCase)) { return; @@ -328,6 +350,54 @@ private async Task ValidateUniqueNameAsync(string name, string currentItemId) } } + private void ValidateSitemap(AIToolInstanceViewModel model) + { + ValidateAbsoluteUrl(model.SitemapBaseUrl, nameof(model.SitemapBaseUrl), "Base URL", required: true); + ValidateAbsoluteUrl(model.SitemapUrl, nameof(model.SitemapUrl), "Sitemap URL", required: false); + } + + private void ValidateSearchIndex(AIToolInstanceViewModel model) + { + ValidateAbsoluteUrl(model.SearchIndexBaseUrl, nameof(model.SearchIndexBaseUrl), "Base URL", required: true); + ValidateAbsoluteUrl(model.SearchIndexUrl, nameof(model.SearchIndexUrl), "Search index URL", required: false); + } + + private void ValidateAlgolia(AIToolInstanceViewModel model) + { + if (string.IsNullOrWhiteSpace(model.AlgoliaApplicationId)) + { + ModelState.AddModelError(nameof(model.AlgoliaApplicationId), "Application ID is required."); + } + + if (string.IsNullOrWhiteSpace(model.AlgoliaApiKey)) + { + ModelState.AddModelError(nameof(model.AlgoliaApiKey), "Search-only API key is required."); + } + + if (string.IsNullOrWhiteSpace(model.AlgoliaIndexName)) + { + ModelState.AddModelError(nameof(model.AlgoliaIndexName), "Index name is required."); + } + } + + private void ValidateAbsoluteUrl(string value, string key, string label, bool required) + { + if (string.IsNullOrWhiteSpace(value)) + { + if (required) + { + ModelState.AddModelError(key, $"{label} is required."); + } + + return; + } + + if (!Uri.TryCreate(value, UriKind.Absolute, out _)) + { + ModelState.AddModelError(key, $"{label} must be a valid absolute URL."); + } + } + private void Apply(AIToolInstanceViewModel model, AIToolInstance instance, bool isNew) { if (isNew) @@ -338,6 +408,44 @@ private void Apply(AIToolInstanceViewModel model, AIToolInstance instance, bool instance.Description = model.Description.Trim(); instance.ModifiedUtc = _timeProvider.GetUtcNow().UtcDateTime; + if (string.Equals(model.Source, DocumentationToolConstants.SitemapSourceName, StringComparison.OrdinalIgnoreCase)) + { + instance.Put(new SitemapDocumentationToolSettings + { + BaseUrl = model.SitemapBaseUrl?.Trim(), + SitemapUrl = string.IsNullOrWhiteSpace(model.SitemapUrl) ? null : model.SitemapUrl.Trim(), + MaxResults = model.SitemapMaxResults, + MaxPages = model.SitemapMaxPages, + }); + + return; + } + + if (string.Equals(model.Source, DocumentationToolConstants.SearchIndexSourceName, StringComparison.OrdinalIgnoreCase)) + { + instance.Put(new SearchIndexDocumentationToolSettings + { + BaseUrl = model.SearchIndexBaseUrl?.Trim(), + IndexUrl = string.IsNullOrWhiteSpace(model.SearchIndexUrl) ? null : model.SearchIndexUrl.Trim(), + MaxResults = model.SearchIndexMaxResults, + }); + + return; + } + + if (string.Equals(model.Source, DocumentationToolConstants.AlgoliaSourceName, StringComparison.OrdinalIgnoreCase)) + { + instance.Put(new AlgoliaDocumentationToolSettings + { + ApplicationId = model.AlgoliaApplicationId?.Trim(), + ApiKey = model.AlgoliaApiKey?.Trim(), + IndexName = model.AlgoliaIndexName?.Trim(), + MaxResults = model.AlgoliaMaxResults, + }); + + return; + } + var protector = _dataProtectionProvider.CreateProtector(HttpApiRequestToolConstants.DataProtectionPurpose); var existing = instance.TryGet(out var stored) ? stored : new HttpApiRequestToolSettings(); @@ -420,6 +528,29 @@ private static AIToolInstanceViewModel ToViewModel(AIToolInstance instance) : "{}"; } + if (instance.TryGet(out var sitemapSettings)) + { + model.SitemapBaseUrl = sitemapSettings.BaseUrl; + model.SitemapUrl = sitemapSettings.SitemapUrl; + model.SitemapMaxResults = sitemapSettings.MaxResults; + model.SitemapMaxPages = sitemapSettings.MaxPages; + } + + if (instance.TryGet(out var searchIndexSettings)) + { + model.SearchIndexBaseUrl = searchIndexSettings.BaseUrl; + model.SearchIndexUrl = searchIndexSettings.IndexUrl; + model.SearchIndexMaxResults = searchIndexSettings.MaxResults; + } + + if (instance.TryGet(out var algoliaSettings)) + { + model.AlgoliaApplicationId = algoliaSettings.ApplicationId; + model.AlgoliaApiKey = algoliaSettings.ApiKey; + model.AlgoliaIndexName = algoliaSettings.IndexName; + model.AlgoliaMaxResults = algoliaSettings.MaxResults; + } + return model; } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/AIToolInstanceViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/AIToolInstanceViewModel.cs index ee8473f1..bd97080f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/AIToolInstanceViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/AIToolInstanceViewModel.cs @@ -142,4 +142,62 @@ public sealed class AIToolInstanceViewModel /// Gets or sets an optional per-request timeout in seconds. /// public int? TimeoutSeconds { get; set; } + + /// + /// Gets or sets the base URL of the documentation site for the sitemap crawling source. + /// + public string SitemapBaseUrl { get; set; } + + /// + /// Gets or sets an explicit sitemap URL for the sitemap crawling source. When empty, the crawler + /// resolves the sitemap from the base URL by appending /sitemap.xml. + /// + public string SitemapUrl { get; set; } + + /// + /// Gets or sets the maximum number of results the sitemap source returns for a single search. + /// + public int? SitemapMaxResults { get; set; } + + /// + /// Gets or sets the maximum number of pages the sitemap crawler indexes for the site. + /// + public int? SitemapMaxPages { get; set; } + + /// + /// Gets or sets the base URL of the documentation site for the prebuilt search index source. + /// + public string SearchIndexBaseUrl { get; set; } + + /// + /// Gets or sets an explicit URL to the search index JSON for the prebuilt search index source. When + /// empty, the source resolves it from the base URL by appending /search/search_index.json. + /// + public string SearchIndexUrl { get; set; } + + /// + /// Gets or sets the maximum number of results the prebuilt search index source returns for a single search. + /// + public int? SearchIndexMaxResults { get; set; } + + /// + /// Gets or sets the Algolia application identifier for the Algolia DocSearch source. + /// + public string AlgoliaApplicationId { get; set; } + + /// + /// Gets or sets the Algolia search-only API key for the Algolia DocSearch source. This is a public, + /// client-safe key and is stored without additional protection. + /// + public string AlgoliaApiKey { get; set; } + + /// + /// Gets or sets the Algolia index name to query for the Algolia DocSearch source. + /// + public string AlgoliaIndexName { get; set; } + + /// + /// Gets or sets the maximum number of results the Algolia DocSearch source returns for a single search. + /// + public int? AlgoliaMaxResults { get; set; } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml index ccf040be..618b8f02 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml @@ -187,6 +187,80 @@ +
+
Documentation site (sitemap crawl)
+
+ + + +
The root URL of the documentation site to crawl.
+
+
+ + + +
Optional. When empty, /sitemap.xml is appended to the base URL.
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
Documentation site (prebuilt search index)
+
+ + + +
The root URL of the documentation site. Used to resolve relative entry locations.
+
+
+ + + +
Optional. When empty, /search/search_index.json is appended to the base URL.
+
+
+ + + +
+
+ +
+
Documentation site (Algolia DocSearch)
+
+ + + +
+
+ + + +
Use the public, search-only key. It is safe to expose to clients and is stored without additional protection.
+
+
+ + + +
+
+ + + +
+
+
Cancel diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index 89c4211f..a5b4a825 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -13,6 +13,7 @@ using CrestApps.Core.AI.Elasticsearch; using CrestApps.Core.AI.Markdown; using CrestApps.Core.AI.Mcp; +using CrestApps.Core.AI.Mcp.Documentation; using CrestApps.Core.AI.Mcp.Ftp; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Mcp.Sftp; @@ -125,16 +126,13 @@ ) .AddToolInstances(toolInstances => toolInstances .AddHttpApiRequestSource() + .AddDocumentationSearchSources() .AddYesSqlStores() ) .AddMcpServer(mcpServer => mcpServer .AddYesSqlStores() .AddFtpResources() .AddSftpResources() - .AddDocumentationSearch(documentation => documentation - .AddYesSqlStores() - .AddSite("crestapps", "https://core.crestapps.com") - ) ) .AddSignalR(addStoreCommitterFilter: true) .AddA2AHost() diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs index d6976d08..c0d91061 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs @@ -147,7 +147,6 @@ public static async Task InitializeYesSqlSchemaAsync(this IServiceProvider servi await TryCreateTableAsync(() => schemaBuilder.CreateAIToolInstanceIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpPromptIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpResourceIndexSchemaAsync(storeOptions)); - await TryCreateTableAsync(() => schemaBuilder.CreateDocumentationSourceEntryIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIDeploymentIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIProfileTemplateIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIChatSessionIndexSchemaAsync(storeOptions)); diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs index 2f6f164d..5050c89c 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs @@ -5,7 +5,6 @@ using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.DataSources; using CrestApps.Core.AI.Documents; -using CrestApps.Core.AI.Mcp.Documentation; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; @@ -205,23 +204,6 @@ public static IServiceCollection AddCoreAIMcpServerStoresEntityCore(this IServic return services; } - /// - /// Registers EntityCore-backed storage for the documentation search feature. This adds a writable - /// binding source for so documentation sources persisted in - /// the store are aggregated by the documentation source catalog alongside options-defined sources. - /// - /// The service collection. - public static IServiceCollection AddCoreAIMcpDocumentationSearchStoresEntityCore(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddScoped>(); - services.AddScoped>(sp => - new WritableCatalogBindingSource(sp.GetRequiredService>())); - - return services; - } - /// /// Registers EntityCore-backed stores for the AI chat sessions feature. /// This includes and . @@ -474,21 +456,6 @@ public static CrestAppsMcpServerBuilder AddEntityCoreStores(this CrestAppsMcpSer return builder; } - /// - /// Registers EntityCore-backed storage for the documentation search feature on the documentation - /// search builder, so documentation sources can be persisted and managed through a UI or database - /// in addition to being registered in code. - /// - /// The documentation search builder. - public static DocumentationSearchBuilder AddEntityCoreStores(this DocumentationSearchBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.Services.AddCoreAIMcpDocumentationSearchStoresEntityCore(); - - return builder; - } - /// /// Registers EntityCore-backed stores for the chat interactions feature on the chat interactions builder. /// This includes a catalog for and . diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndex.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndex.cs deleted file mode 100644 index 833ec6aa..00000000 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndex.cs +++ /dev/null @@ -1,53 +0,0 @@ -using CrestApps.Core.AI.Mcp.Documentation; -using Microsoft.Extensions.Options; -using YesSql.Indexes; - -namespace CrestApps.Core.Data.YesSql.Indexes.Mcp; - -/// -/// YesSql map index for , storing the item identifier, unique -/// name, and strategy (source) to support efficient documentation source catalog queries. -/// -public sealed class DocumentationSourceEntryIndex : CatalogItemIndex, INameAwareIndex, ISourceAwareIndex -{ - /// - /// Gets or sets the unique logical name of the documentation source. - /// - public string Name { get; set; } - - /// - /// Gets or sets the search strategy identifier of the documentation source. - /// - public string Source { get; set; } -} - -/// -/// YesSql index provider that maps documents to -/// entries in the AI collection. -/// -public sealed class DocumentationSourceEntryIndexProvider : IndexProvider -{ - /// - /// Initializes a new instance of the class. - /// - /// The options. - public DocumentationSourceEntryIndexProvider(IOptions options) - { - CollectionName = options.Value.AICollectionName; - } - - /// - /// Describes the index mapping. - /// - /// The context. - public override void Describe(DescribeContext context) - { - context.For() - .Map(entry => new DocumentationSourceEntryIndex - { - ItemId = entry.ItemId, - Name = entry.Name, - Source = entry.Source, - }); - } -} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndexSchemaBuilderExtensions.cs deleted file mode 100644 index 4ab3f352..00000000 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/DocumentationSourceEntryIndexSchemaBuilderExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using YesSql.Sql; - -namespace CrestApps.Core.Data.YesSql.Indexes.Mcp; - -/// -/// Schema builder extensions for the table. -/// -public static class DocumentationSourceEntryIndexSchemaBuilderExtensions -{ - /// - /// Creates the documentation source entry index schema. - /// - /// The schema builder. - /// The options. - public static async Task CreateDocumentationSourceEntryIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) - { - ArgumentNullException.ThrowIfNull(schemaBuilder); - ArgumentNullException.ThrowIfNull(options); - - await schemaBuilder.CreateMapIndexTableAsync(table => table - .Column(nameof(DocumentationSourceEntryIndex.ItemId), column => column.WithLength(26)) - .Column(nameof(DocumentationSourceEntryIndex.Name), column => column.WithLength(255)) - .Column(nameof(DocumentationSourceEntryIndex.Source), column => column.WithLength(255)), - collection: options?.AICollectionName); - - await schemaBuilder.AlterIndexTableAsync( - table => table.CreateIndex("IDX_DocumentationSourceEntry_DocumentId", "DocumentId", nameof(DocumentationSourceEntryIndex.Name)), - collection: options?.AICollectionName); - - await schemaBuilder.AlterIndexTableAsync( - table => table.CreateIndex("IDX_DocumentationSourceEntry_Source", "DocumentId", nameof(DocumentationSourceEntryIndex.Source)), - collection: options?.AICollectionName); - } -} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs index 58ce0d0c..6acdf82b 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs @@ -5,7 +5,6 @@ using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.DataSources; using CrestApps.Core.AI.Documents; -using CrestApps.Core.AI.Mcp.Documentation; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; @@ -180,21 +179,6 @@ public static CrestAppsMcpServerBuilder AddYesSqlStores(this CrestAppsMcpServerB return builder; } - /// - /// Registers YesSql-backed storage for the documentation search feature on the documentation search - /// builder, so documentation sources can be persisted and managed through a UI or database in - /// addition to being registered in code. - /// - /// The documentation search builder. - public static DocumentationSearchBuilder AddYesSqlStores(this DocumentationSearchBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.Services.AddCoreAIMcpDocumentationSearchStoresYesSql(); - - return builder; - } - /// /// Registers YesSql-backed stores for the chat interactions feature on the chat interactions builder. /// This includes a catalog for and . @@ -397,32 +381,6 @@ public static IServiceCollection AddCoreAIMcpServerStoresYesSql(this IServiceCol return services; } - /// - /// Registers YesSql-backed storage for the documentation search feature. This adds a writable - /// binding source for so documentation sources persisted in - /// the store are aggregated by the documentation source catalog alongside options-defined sources. - /// - /// The service collection. - public static IServiceCollection AddCoreAIMcpDocumentationSearchStoresYesSql(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddScoped(sp => - { - var session = sp.GetRequiredService(); - var options = sp.GetRequiredService>().Value; - - return new NamedSourceDocumentCatalog(session, options.AICollectionName); - }); - - services.AddScoped>(sp => - new WritableCatalogBindingSource(sp.GetRequiredService>())); - - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - - return services; - } - /// /// Registers all YesSql-backed stores for the AI chat sessions feature. /// This is a convenience method that registers the core chat session stores diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs index 35b6323d..ed20aaa4 100644 --- a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs @@ -1,277 +1,199 @@ -using CrestApps.Core.AI.Mcp; using CrestApps.Core.AI.Mcp.Documentation; -using CrestApps.Core.AI.Mcp.Functions; using CrestApps.Core.AI.Tooling; -using CrestApps.Core.Services; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; namespace CrestApps.Core.Tests.Core.Mcp; public sealed class DocumentationSearchTests { /// - /// Verifies that the registration extension registers the documentation search tool with the - /// expected category and purpose so it can be exposed and filtered by a knowledge-base MCP server. + /// Verifies that the sitemap source materializes a documentation search function whose model-facing + /// name and description are derived from the configured instance. /// [Fact] - public void AddCoreAIDocumentationSearch_RegistersToolWithCategoryAndPurpose() + public void SitemapSource_CreateTool_DerivesNameAndDescriptionFromInstance() { - var services = new ServiceCollection(); - services.AddLogging(); - services.AddCoreAIDocumentationSearch(); + var instance = new AIToolInstance + { + ItemId = "instance-1", + Source = DocumentationToolConstants.SitemapSourceName, + Name = "crestapps-docs", + Description = "Searches the CrestApps documentation.", + }; - using var provider = services.BuildServiceProvider(); + instance.Put(new SitemapDocumentationToolSettings + { + BaseUrl = "https://core.crestapps.com", + }); + + var source = new SitemapDocumentationToolSource(); + var tool = source.CreateTool(instance); - var options = provider.GetRequiredService>().Value; + var function = Assert.IsType(tool); - Assert.True(options.Tools.TryGetValue(DocumentationSearchFunction.TheName, out var entry)); - Assert.Equal(DocumentationSearchFunction.Category, entry.Category); - Assert.Equal(AIToolPurposes.DataSourceSearch, entry.Purpose); - Assert.NotNull(provider.GetRequiredService()); - Assert.NotNull(provider.GetRequiredService()); + Assert.Equal(instance.GetFunctionName(), function.Name); + Assert.Equal("Searches the CrestApps documentation.", function.Description); } /// - /// Verifies that records the site in the options. + /// Verifies that the search-index source produces a documentation search function. /// [Fact] - public void AddSite_PopulatesOptions() + public void SearchIndexSource_CreateTool_ProducesFunction() { - var services = new ServiceCollection(); - services.AddLogging(); - services.AddCoreAIDocumentationSearch(docs => docs - .AddSite("crestapps", "https://core.crestapps.com", site => site.MaxResults = 3)); + var instance = new AIToolInstance + { + ItemId = "instance-2", + Source = DocumentationToolConstants.SearchIndexSourceName, + Name = "mkdocs", + }; - using var provider = services.BuildServiceProvider(); + instance.Put(new SearchIndexDocumentationToolSettings + { + BaseUrl = "https://docs.example.com", + IndexUrl = "https://docs.example.com/search/search_index.json", + }); - var options = provider.GetRequiredService>().Value; - var site = Assert.Single(options.Sites); + var source = new SearchIndexDocumentationToolSource(); + var tool = source.CreateTool(instance); - Assert.Equal("crestapps", site.Name); - Assert.Equal("https://core.crestapps.com", site.BaseUrl); - Assert.Equal(3, site.MaxResults); + Assert.IsType(tool); } /// - /// Verifies that records the search-index - /// site in the options. + /// Verifies that the Algolia source produces a documentation search function. /// [Fact] - public void AddSearchIndex_PopulatesOptions() + public void AlgoliaSource_CreateTool_ProducesFunction() { - var services = new ServiceCollection(); - services.AddLogging(); - services.AddCoreAIDocumentationSearch(docs => docs - .AddSearchIndex("mkdocs", "https://docs.example.com", site => - { - site.IndexUrl = "https://docs.example.com/search/search_index.json"; - site.MaxResults = 4; - })); + var instance = new AIToolInstance + { + ItemId = "instance-3", + Source = DocumentationToolConstants.AlgoliaSourceName, + Name = "algolia", + }; - using var provider = services.BuildServiceProvider(); + instance.Put(new AlgoliaDocumentationToolSettings + { + ApplicationId = "APP123", + ApiKey = "search-key", + IndexName = "docs-index", + }); - var options = provider.GetRequiredService>().Value; - var site = Assert.Single(options.SearchIndexes); + var source = new AlgoliaDocumentationToolSource(); + var tool = source.CreateTool(instance); - Assert.Equal("mkdocs", site.Name); - Assert.Equal("https://docs.example.com", site.BaseUrl); - Assert.Equal("https://docs.example.com/search/search_index.json", site.IndexUrl); - Assert.Equal(4, site.MaxResults); + Assert.IsType(tool); } /// - /// Verifies that records the Algolia - /// site in the options. + /// Verifies that invoking the function returns a helpful message when the required query is missing. /// [Fact] - public void AddAlgoliaDocSearch_PopulatesOptions() + public async Task InvokeAsync_WhenQueryMissing_ReturnsMessage() { - var services = new ServiceCollection(); - services.AddLogging(); - services.AddCoreAIDocumentationSearch(docs => docs - .AddAlgoliaDocSearch("algolia", "APP123", "search-key", "docs-index", site => site.MaxResults = 6)); - - using var provider = services.BuildServiceProvider(); + var function = CreateFunction(new FakeDocumentationSource("docs")); - var options = provider.GetRequiredService>().Value; - var site = Assert.Single(options.AlgoliaSources); + var result = await InvokeAsync(function, query: null); - Assert.Equal("algolia", site.Name); - Assert.Equal("APP123", site.ApplicationId); - Assert.Equal("search-key", site.ApiKey); - Assert.Equal("docs-index", site.IndexName); - Assert.Equal(6, site.MaxResults); + Assert.Contains("query", result, StringComparison.OrdinalIgnoreCase); } /// - /// Verifies that the source provider aggregates code-registered custom sources with the built-in - /// crawler sources materialized from the configured sites. + /// Verifies that invoking the function returns a message when the source yields no results. /// [Fact] - public async Task SourceProvider_AggregatesCustomAndConfiguredSites() + public async Task InvokeAsync_WhenNoResults_ReturnsMessage() { - var services = new ServiceCollection(); - services.AddLogging(); - services.AddCoreAIDocumentationSearch(docs => docs - .AddSite("site-1", "https://docs.example.com") - .AddSearchIndex("index-1", "https://mkdocs.example.com") - .AddAlgoliaDocSearch("algolia-1", "APP123", "search-key", "docs-index") - .AddSource(new FakeDocumentationSource("custom-1"))); + var function = CreateFunction(new FakeDocumentationSource("docs")); - using var provider = services.BuildServiceProvider(); + var result = await InvokeAsync(function, "anything"); - var sources = await provider.GetRequiredService() - .GetSourcesAsync(provider, TestContext.Current.CancellationToken); - - Assert.Contains(sources, source => source.Name == "custom-1"); - Assert.Contains(sources, source => source.Name == "site-1"); - Assert.Contains(sources, source => source.Name == "index-1"); - Assert.Contains(sources, source => source.Name == "algolia-1"); + Assert.Equal("No documentation results were found for 'anything'.", result); } /// - /// Verifies that the source provider materializes documentation sources stored in the catalog (for - /// example a database-backed store) through the registered strategy factories. + /// Verifies that invoking the function formats the results returned by the bound source. /// [Fact] - public async Task SourceProvider_MaterializesCatalogEntries() + public async Task InvokeAsync_FormatsResults() { - var services = new ServiceCollection(); - services.AddLogging(); - services.AddCoreAIDocumentationSearch(); - services.AddScoped>(_ => new FakeCatalogSource( - new DocumentationSourceEntry + var source = new FakeDocumentationSource( + "docs", + new DocumentationSearchResult { - ItemId = "01HZZZDBSOURCE0000000000001", - Name = "stored-site", - Source = DocumentationSourceStrategies.Sitemap, - BaseUrl = "https://stored.example.com", - })); - - using var root = services.BuildServiceProvider(); - using var scope = root.CreateScope(); - - var sources = await scope.ServiceProvider.GetRequiredService() - .GetSourcesAsync(scope.ServiceProvider, TestContext.Current.CancellationToken); - - Assert.Contains(sources, source => source.Name == "stored-site"); - } + SourceName = "docs", + Title = "Getting started", + Url = "https://core.crestapps.com/start", + Snippet = "How to begin.", + Score = 5, + }); - /// - /// Verifies that the tool returns a helpful message when no documentation sources are configured. - /// - [Fact] - public async Task InvokeAsync_WhenNoSourcesConfigured_ReturnsMessage() - { - using var provider = BuildProvider(); + var function = CreateFunction(source); - var result = await InvokeAsync(provider, "anything"); + var result = await InvokeAsync(function, "start"); - Assert.Equal("No documentation sources are configured.", result); + Assert.Contains("[1] Getting started — https://core.crestapps.com/start", result); + Assert.Contains("How to begin.", result); } /// - /// Verifies that the tool aggregates results across sources and orders them by descending score. + /// Verifies that the materializer caches the built source until the signature changes. /// [Fact] - public async Task InvokeAsync_AggregatesResultsOrderedByScore() + public void Materializer_CachesUntilSignatureChanges() { - var low = new FakeDocumentationSource("source-a", new DocumentationSearchResult - { - SourceName = "source-a", - Title = "Low", - Url = "https://a/low", - Snippet = "low snippet", - Score = 1, - }); + var materializer = new DefaultDocumentationSourceMaterializer(); + var buildCount = 0; - var high = new FakeDocumentationSource("source-b", new DocumentationSearchResult + IDocumentationSource Factory() { - SourceName = "source-b", - Title = "High", - Url = "https://b/high", - Snippet = "high snippet", - Score = 9, - }); + buildCount++; - using var provider = BuildProvider(low, high); + return new FakeDocumentationSource("docs"); + } - var result = await InvokeAsync(provider, "topic"); + var first = materializer.GetOrCreate("key", "sig-1", Factory); + var second = materializer.GetOrCreate("key", "sig-1", Factory); - Assert.Contains("[1] High — https://b/high", result); - Assert.Contains("[2] Low — https://a/low", result); - Assert.True(result.IndexOf("High", StringComparison.Ordinal) < result.IndexOf("Low", StringComparison.Ordinal)); - } + Assert.Same(first, second); + Assert.Equal(1, buildCount); - /// - /// Verifies that supplying an unknown source name returns a message instead of silently searching all. - /// - [Fact] - public async Task InvokeAsync_WithUnknownSource_ReturnsMessage() - { - using var provider = BuildProvider(new FakeDocumentationSource("known")); - - var result = await InvokeAsync(provider, "topic", "missing"); + var third = materializer.GetOrCreate("key", "sig-2", Factory); - Assert.Equal("No documentation source named 'missing' is configured.", result); + Assert.NotSame(first, third); + Assert.Equal(2, buildCount); } - /// - /// Verifies that supplying a source name scopes the search to that single source. - /// - [Fact] - public async Task InvokeAsync_WithSourceName_ScopesToNamedSource() + private static DocumentationSearchToolFunction CreateFunction(IDocumentationSource source) { - var wanted = new FakeDocumentationSource("wanted", new DocumentationSearchResult + var instance = new AIToolInstance { - SourceName = "wanted", - Title = "Wanted", - Url = "https://wanted/doc", - Snippet = "wanted snippet", - Score = 5, - }); - - var other = new FakeDocumentationSource("other", new DocumentationSearchResult - { - SourceName = "other", - Title = "Other", - Url = "https://other/doc", - Snippet = "other snippet", - Score = 8, - }); - - using var provider = BuildProvider(wanted, other); - - var result = await InvokeAsync(provider, "topic", "wanted"); + ItemId = "instance-1", + Name = "docs", + CreatedUtc = DateTime.UnixEpoch, + }; - Assert.Contains("Wanted", result); - Assert.DoesNotContain("Other", result); + return new DocumentationSearchToolFunction("docs", "Docs search", instance, _ => source); } - private static ServiceProvider BuildProvider(params IDocumentationSource[] sources) + private static async Task InvokeAsync(DocumentationSearchToolFunction function, string query) { var services = new ServiceCollection(); services.AddLogging(); - services.AddSingleton(new StubSourceProvider(sources)); + services.AddSingleton(); - return services.BuildServiceProvider(); - } + using var provider = services.BuildServiceProvider(); - private static async Task InvokeAsync(IServiceProvider provider, string query, string source = null) - { - var function = new DocumentationSearchFunction(); var arguments = new AIFunctionArguments { Services = provider, }; - arguments["query"] = query; - - if (source is not null) + if (query is not null) { - arguments["source"] = source; + arguments["query"] = query; } var result = await function.InvokeAsync(arguments, TestContext.Current.CancellationToken); @@ -279,38 +201,6 @@ private static async Task InvokeAsync(IServiceProvider provider, string return result?.ToString(); } - private sealed class StubSourceProvider : IDocumentationSourceProvider - { - private readonly IReadOnlyList _sources; - - public StubSourceProvider(IReadOnlyList sources) - { - _sources = sources; - } - - public ValueTask> GetSourcesAsync(IServiceProvider services, CancellationToken cancellationToken = default) - { - return ValueTask.FromResult(_sources); - } - } - - private sealed class FakeCatalogSource : INamedSourceCatalogSource - { - private readonly IReadOnlyCollection _entries; - - public FakeCatalogSource(params DocumentationSourceEntry[] entries) - { - _entries = entries; - } - - public int Order => 0; - - public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries, CancellationToken cancellationToken = default) - { - return ValueTask.FromResult(_entries); - } - } - private sealed class FakeDocumentationSource : IDocumentationSource { private readonly DocumentationSearchResult[] _results; diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs index ab3c0fd7..e2399501 100644 --- a/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs @@ -2,6 +2,7 @@ using CrestApps.Core.AI.Mcp; using CrestApps.Core.AI.Mcp.Services; using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Services; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -10,65 +11,64 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Moq; +using ServerToolOptions = CrestApps.Core.AI.Mcp.Models.McpServerOptions; namespace CrestApps.Core.Tests.Core.Mcp; public sealed class McpServerBuilderExtensionsTests { /// - /// Verifies that a capability toggle set through options overrides the value chosen in code, so a - /// host can enable or disable capabilities from configuration without changing code. + /// Verifies that with the default settings (nothing allowed and ExposeAllTools off) no tools + /// are listed, even when tools are registered, so an MCP server exposes nothing until explicitly told to. /// [Fact] - public void ApplyOptions_ConfigurationWinsOverCode() + public async Task ListToolsHandler_DefaultDeny_ReturnsEmpty() { - var handlerBuilder = new CrestAppsMcpHandlerBuilder(); - handlerBuilder.WithoutTools(); - handlerBuilder.WithoutSdkTools(); + var services = CreateServices(); - handlerBuilder.ApplyOptions(new McpServerHandlerOptions - { - IncludeTools = true, - IncludeSdkTools = true, - IncludePrompts = false, - }); + AddLocalTool(services, "search-key", new TestAIFunction("search")); + AddLocalTool(services, "create-key", new TestAIFunction("create")); - Assert.True(handlerBuilder.IncludeTools); - Assert.True(handlerBuilder.IncludeSdkTools); - Assert.False(handlerBuilder.IncludePrompts); + using var serviceProvider = services.BuildServiceProvider(); + + var result = await InvokeListToolsHandlerAsync( + serviceProvider, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(result.Tools); } /// - /// Verifies that a capability toggle leaves the value chosen in code intact, - /// so unset configuration does not override explicit code choices. + /// Verifies that enabling ExposeAllTools lists every non-hidden tool while hidden tools are omitted. /// [Fact] - public void ApplyOptions_NullTogglesPreserveCodeValues() + public async Task ListToolsHandler_ExposeAllTools_ReturnsVisibleToolsAndOmitsHidden() { - var handlerBuilder = new CrestAppsMcpHandlerBuilder(); - handlerBuilder.WithoutResources(); + var services = CreateServices(configureOptions: options => options.ExposeAllTools = true); + + AddLocalTool(services, "search-key", new TestAIFunction("search")); + AddLocalTool(services, "hidden-key", new TestAIFunction("hidden"), hidden: true); + AddLocalTool(services, "create-key", new TestAIFunction("create")); + + using var serviceProvider = services.BuildServiceProvider(); - handlerBuilder.ApplyOptions(new McpServerHandlerOptions()); + var result = await InvokeListToolsHandlerAsync( + serviceProvider, + cancellationToken: TestContext.Current.CancellationToken); - Assert.True(handlerBuilder.IncludeTools); - Assert.True(handlerBuilder.IncludeSdkTools); - Assert.True(handlerBuilder.IncludePrompts); - Assert.False(handlerBuilder.IncludeResources); + Assert.Equal(["search", "create"], result.Tools.Select(tool => tool.Name)); } /// - /// Verifies that visible local tools retain registration order, precede SDK tools, and hidden tools are omitted. + /// Verifies that the allow-list exposes only the tools whose registration key is listed. /// [Fact] - public async Task ListToolsHandler_ReturnsVisibleLocalToolsFirstAndOmitsHiddenTools() + public async Task ListToolsHandler_AllowList_ExposesOnlyNamedTools() { - var services = CreateServices( - CreateSdkTool("sdk-first"), - CreateSdkTool("sdk-second")); + var services = CreateServices(configureOptions: options => options.Tools = ["search-key"]); - AddLocalTool(services, "local-first-key", new TestAIFunction("local-first")); - AddLocalTool(services, "hidden-key", new TestAIFunction("hidden"), hidden: true); - AddLocalTool(services, "local-second-key", new TestAIFunction("local-second")); + AddLocalTool(services, "search-key", new TestAIFunction("search")); + AddLocalTool(services, "create-key", new TestAIFunction("create")); using var serviceProvider = services.BuildServiceProvider(); @@ -76,18 +76,35 @@ public async Task ListToolsHandler_ReturnsVisibleLocalToolsFirstAndOmitsHiddenTo serviceProvider, cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal( - ["local-first", "local-second", "sdk-first", "sdk-second"], - result.Tools.Select(tool => tool.Name)); + Assert.Equal(["search"], result.Tools.Select(tool => tool.Name)); } /// - /// Verifies that keyed local tool creation failures are logged and skipped. + /// Verifies that allow-list matching is case-insensitive. + /// + [Fact] + public async Task ListToolsHandler_AllowList_MatchesNameCaseInsensitively() + { + var services = CreateServices(configureOptions: options => options.Tools = ["SEARCH-KEY"]); + + AddLocalTool(services, "search-key", new TestAIFunction("search")); + + using var serviceProvider = services.BuildServiceProvider(); + + var result = await InvokeListToolsHandlerAsync( + serviceProvider, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(["search"], result.Tools.Select(tool => tool.Name)); + } + + /// + /// Verifies that keyed tool creation failures are logged and skipped instead of failing the whole list. /// [Fact] public async Task ListToolsHandler_LogsAndSkipsKeyedServiceCreationFailures() { - var services = CreateServices(); + var services = CreateServices(configureOptions: options => options.ExposeAllTools = true); var logger = new Mock>(); var failure = new InvalidOperationException("Tool creation failed."); @@ -119,15 +136,22 @@ public async Task ListToolsHandler_LogsAndSkipsKeyedServiceCreationFailures() } /// - /// Verifies that duplicate protocol names produced by distinct local registrations remain in the result. + /// Verifies that a configured tool instance is exposed when its name is on the allow-list. /// [Fact] - public async Task ListToolsHandler_PreservesDuplicateNamesProducedByLocalTools() + public async Task ListToolsHandler_AllowList_ExposesNamedToolInstance() { - var services = CreateServices(CreateSdkTool("sdk")); + var instance = new AIToolInstance + { + ItemId = "instance-1", + Source = "docs-source", + Name = "crestapps-docs", + }; + + var services = CreateServices(configureOptions: options => options.Tools = ["crestapps-docs"]); - AddLocalTool(services, "first-key", new TestAIFunction("duplicate")); - AddLocalTool(services, "second-key", new TestAIFunction("duplicate")); + AddToolInstances(services, instance); + AddToolInstanceSource(services, "docs-source"); using var serviceProvider = services.BuildServiceProvider(); @@ -135,19 +159,26 @@ public async Task ListToolsHandler_PreservesDuplicateNamesProducedByLocalTools() serviceProvider, cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(["duplicate", "duplicate", "sdk"], result.Tools.Select(tool => tool.Name)); + Assert.Equal(["crestapps-docs"], result.Tools.Select(tool => tool.Name)); } /// - /// Verifies that SDK tools are appended in service enumeration order. + /// Verifies that configured tool instances are exposed when ExposeAllTools is enabled. /// [Fact] - public async Task ListToolsHandler_AppendsSdkToolsInEnumerationOrder() + public async Task ListToolsHandler_ExposeAll_IncludesToolInstances() { - var services = CreateServices( - CreateSdkTool("sdk-third"), - CreateSdkTool("sdk-first"), - CreateSdkTool("sdk-second")); + var instance = new AIToolInstance + { + ItemId = "instance-1", + Source = "docs-source", + Name = "crestapps-docs", + }; + + var services = CreateServices(configureOptions: options => options.ExposeAllTools = true); + + AddToolInstances(services, instance); + AddToolInstanceSource(services, "docs-source"); using var serviceProvider = services.BuildServiceProvider(); @@ -155,25 +186,26 @@ public async Task ListToolsHandler_AppendsSdkToolsInEnumerationOrder() serviceProvider, cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal( - ["sdk-third", "sdk-first", "sdk-second"], - result.Tools.Select(tool => tool.Name)); + Assert.Contains(result.Tools, tool => tool.Name == "crestapps-docs"); } /// - /// Verifies that a local tool takes precedence over an SDK tool with the same exact name. + /// Verifies that configured tool instances are not exposed by default. /// [Fact] - public async Task ListToolsHandler_SkipsSdkToolsThatDuplicateLocalNames() + public async Task ListToolsHandler_DefaultDeny_OmitsToolInstances() { - var duplicateSdkTool = CreateSdkTool("duplicate", "SDK duplicate"); - var services = CreateServices(duplicateSdkTool, CreateSdkTool("sdk")); - var localDescription = "Local duplicate"; + var instance = new AIToolInstance + { + ItemId = "instance-1", + Source = "docs-source", + Name = "crestapps-docs", + }; - AddLocalTool( - services, - "local-key", - new TestAIFunction("duplicate", localDescription)); + var services = CreateServices(); + + AddToolInstances(services, instance); + AddToolInstanceSource(services, "docs-source"); using var serviceProvider = services.BuildServiceProvider(); @@ -181,149 +213,139 @@ public async Task ListToolsHandler_SkipsSdkToolsThatDuplicateLocalNames() serviceProvider, cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(["duplicate", "sdk"], result.Tools.Select(tool => tool.Name)); - Assert.Equal(localDescription, result.Tools[0].Description); - Assert.DoesNotContain(result.Tools, tool => ReferenceEquals(tool, duplicateSdkTool.ProtocolTool)); + Assert.Empty(result.Tools); } /// - /// Verifies that later SDK tools with duplicate names are skipped while the first instance is retained. + /// Verifies that a tool not on the allow-list cannot be invoked through the call handler. /// [Fact] - public async Task ListToolsHandler_SkipsDuplicateNamesWithinSdkTools() + public async Task CallToolHandler_DefaultDeny_RejectsTool() { - var first = CreateSdkTool("duplicate", "first"); - var second = CreateSdkTool("duplicate", "second"); - var unique = CreateSdkTool("unique"); - var services = CreateServices(first, second, unique); + var services = CreateServices(); - using var serviceProvider = services.BuildServiceProvider(); + AddLocalTool(services, "search", new TestAIFunction("search")); - var result = await InvokeListToolsHandlerAsync( - serviceProvider, - cancellationToken: TestContext.Current.CancellationToken); + using var serviceProvider = services.BuildServiceProvider(); - Assert.Equal(["duplicate", "unique"], result.Tools.Select(tool => tool.Name)); - Assert.Same(first.ProtocolTool, result.Tools[0]); - Assert.DoesNotContain(result.Tools, tool => ReferenceEquals(tool, second.ProtocolTool)); + await Assert.ThrowsAsync(async () => + await InvokeCallToolHandlerAsync( + serviceProvider, + "search", + TestContext.Current.CancellationToken)); } /// - /// Verifies that duplicate matching uses ordinal case-sensitive equality. + /// Verifies that an allow-listed tool can be invoked through the call handler. /// [Fact] - public async Task ListToolsHandler_TreatsToolNamesAsOrdinalCaseSensitive() + public async Task CallToolHandler_InvokesAllowedTool() { - var services = CreateServices( - CreateSdkTool("casetool"), - CreateSdkTool("CaseTool")); + var services = CreateServices(configureOptions: options => options.Tools = ["search"]); - AddLocalTool(services, "local-key", new TestAIFunction("CaseTool")); + AddLocalTool(services, "search", new TestAIFunction("search")); using var serviceProvider = services.BuildServiceProvider(); - var result = await InvokeListToolsHandlerAsync( + var result = await InvokeCallToolHandlerAsync( serviceProvider, - cancellationToken: TestContext.Current.CancellationToken); + "search", + TestContext.Current.CancellationToken); - Assert.Equal(["CaseTool", "casetool"], result.Tools.Select(tool => tool.Name)); + Assert.NotNull(result); } /// - /// Verifies that a service provider returning a null SDK tool enumerable produces an empty result. + /// Verifies that a tool advertised by its function name can be invoked even when the function name + /// differs from the registration key it is keyed under, mirroring how the list handler publishes the + /// function name rather than the key. /// [Fact] - public async Task ListToolsHandler_AllowsNullSdkToolEnumerable() + public async Task CallToolHandler_InvokesTool_WhenFunctionNameDiffersFromKey() { - var services = CreateServices(); + var services = CreateServices(configureOptions: options => options.Tools = ["search-key"]); + + AddLocalTool(services, "search-key", new TestAIFunction("search")); using var serviceProvider = services.BuildServiceProvider(); - var requestServices = new NullSdkEnumerableServiceProvider(serviceProvider); - var result = await InvokeListToolsHandlerAsync( + var result = await InvokeCallToolHandlerAsync( serviceProvider, - requestServices, + "search", TestContext.Current.CancellationToken); - Assert.Empty(result.Tools); + Assert.NotNull(result); } - - /// - /// Verifies that the default DI empty SDK tool enumerable leaves local tools unchanged. - /// [Fact] - public async Task ListToolsHandler_AllowsDefaultEmptySdkToolEnumerable() + public async Task CallToolHandler_ExposeAll_InvokesTool() { - var services = CreateServices(); + var services = CreateServices(configureOptions: options => options.ExposeAllTools = true); - AddLocalTool(services, "local-key", new TestAIFunction("local")); + AddLocalTool(services, "search", new TestAIFunction("search")); using var serviceProvider = services.BuildServiceProvider(); - var sdkTools = serviceProvider.GetRequiredService>(); - Assert.Empty(sdkTools); - - var result = await InvokeListToolsHandlerAsync( + var result = await InvokeCallToolHandlerAsync( serviceProvider, - cancellationToken: TestContext.Current.CancellationToken); + "search", + TestContext.Current.CancellationToken); - Assert.Equal(["local"], result.Tools.Select(tool => tool.Name)); + Assert.NotNull(result); } /// - /// Verifies that local metadata values and SDK protocol tool instances retain their identity. + /// Verifies that an allow-listed tool instance can be invoked through the call handler by its name. /// [Fact] - public async Task ListToolsHandler_PreservesToolSchemaDescriptionAndSdkProtocolIdentity() + public async Task CallToolHandler_InvokesAllowedToolInstance() { - var schema = JsonSerializer.Deserialize( - """ + var instance = new AIToolInstance { - "type": "object", - "properties": { - "value": { - "type": "integer" - } - } - } - """); - var description = new string("Local description".ToCharArray()); - var localTool = new TestAIFunction("local", description, schema); - var sdkTool = CreateSdkTool("sdk"); - var services = CreateServices(sdkTool); + ItemId = "instance-1", + Source = "docs-source", + Name = "crestapps-docs", + }; + + var services = CreateServices(configureOptions: options => options.Tools = ["crestapps-docs"]); - AddLocalTool(services, "local-key", localTool); + AddToolInstances(services, instance); + AddToolInstanceSource(services, "docs-source"); using var serviceProvider = services.BuildServiceProvider(); - var result = await InvokeListToolsHandlerAsync( + var result = await InvokeCallToolHandlerAsync( serviceProvider, - cancellationToken: TestContext.Current.CancellationToken); + "crestapps-docs", + TestContext.Current.CancellationToken); - Assert.Same(description, result.Tools[0].Description); - Assert.Equal(localTool.JsonSchema, result.Tools[0].InputSchema); - Assert.Same(sdkTool.ProtocolTool, result.Tools[1]); + Assert.NotNull(result); } /// - /// Verifies the current synchronous list handler behavior when passed an already-canceled token. + /// Verifies that a tool instance not on the allow-list cannot be invoked through the call handler. /// [Fact] - public async Task ListToolsHandler_DoesNotObserveCancellation() + public async Task CallToolHandler_DefaultDeny_RejectsToolInstance() { - var services = CreateServices(CreateSdkTool("sdk")); + var instance = new AIToolInstance + { + ItemId = "instance-1", + Source = "docs-source", + Name = "crestapps-docs", + }; - AddLocalTool(services, "local-key", new TestAIFunction("local")); + var services = CreateServices(); - using var serviceProvider = services.BuildServiceProvider(); - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); + AddToolInstances(services, instance); + AddToolInstanceSource(services, "docs-source"); - var result = await InvokeListToolsHandlerAsync( - serviceProvider, - cancellationToken: cancellationTokenSource.Token); + using var serviceProvider = services.BuildServiceProvider(); - Assert.Equal(["local", "sdk"], result.Tools.Select(tool => tool.Name)); + await Assert.ThrowsAsync(async () => + await InvokeCallToolHandlerAsync( + serviceProvider, + "crestapps-docs", + TestContext.Current.CancellationToken)); } /// @@ -383,180 +405,54 @@ public void WithoutResources_OmitsResourceHandlersOnly() } /// - /// Verifies that a category filter exposes only tools assigned to a matching category. - /// - [Fact] - public async Task ListToolsHandler_WithToolsInCategory_ExposesOnlyMatchingCategory() - { - var services = CreateServices(handlers => handlers.WithToolsInCategory("knowledgebase")); - - AddLocalTool(services, "search-key", new TestAIFunction("search"), entry => entry.Category = "knowledgebase"); - AddLocalTool(services, "create-key", new TestAIFunction("create"), entry => entry.Category = "content"); - - using var serviceProvider = services.BuildServiceProvider(); - - var result = await InvokeListToolsHandlerAsync( - serviceProvider, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal(["search"], result.Tools.Select(tool => tool.Name)); - } - - /// - /// Verifies that a purpose filter exposes only tools tagged with a matching purpose. - /// - [Fact] - public async Task ListToolsHandler_WithToolsForPurpose_ExposesOnlyMatchingPurpose() - { - var services = CreateServices(handlers => handlers.WithToolsForPurpose(AIToolPurposes.DataSourceSearch)); - - AddLocalTool(services, "search-key", new TestAIFunction("search"), entry => entry.Purpose = AIToolPurposes.DataSourceSearch); - AddLocalTool(services, "image-key", new TestAIFunction("image"), entry => entry.Purpose = AIToolPurposes.ContentGeneration); - - using var serviceProvider = services.BuildServiceProvider(); - - var result = await InvokeListToolsHandlerAsync( - serviceProvider, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal(["search"], result.Tools.Select(tool => tool.Name)); - } - - /// - /// Verifies that a name filter exposes only the explicitly named tools. + /// Creates the MCP service collection and registers the CrestApps handlers. /// - [Fact] - public async Task ListToolsHandler_WithToolNames_ExposesOnlyNamedTools() + /// An optional handler configuration delegate. + /// An optional delegate that configures the exposure settings. + /// The configured service collection. + private static ServiceCollection CreateServices( + Action configure = null, + Action configureOptions = null) { - var services = CreateServices(handlers => handlers.WithToolNames("search-key")); - - AddLocalTool(services, "search-key", new TestAIFunction("search")); - AddLocalTool(services, "create-key", new TestAIFunction("create")); - - using var serviceProvider = services.BuildServiceProvider(); - - var result = await InvokeListToolsHandlerAsync( - serviceProvider, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal(["search"], result.Tools.Select(tool => tool.Name)); - } + var services = new ServiceCollection(); + var builder = services.AddMcpServer(); - /// - /// Verifies that combined filters are AND-composed so a tool must satisfy every filter. - /// - [Fact] - public async Task ListToolsHandler_CombinedFilters_RequireEveryFilterToMatch() - { - var services = CreateServices(handlers => handlers - .WithToolsInCategory("knowledgebase") - .WithToolsForPurpose(AIToolPurposes.DataSourceSearch)); + services.AddOptions(); + services.AddOptions(); - AddLocalTool(services, "match-key", new TestAIFunction("match"), entry => + if (configureOptions is not null) { - entry.Category = "knowledgebase"; - entry.Purpose = AIToolPurposes.DataSourceSearch; - }); - AddLocalTool(services, "category-only-key", new TestAIFunction("category-only"), entry => entry.Category = "knowledgebase"); - - using var serviceProvider = services.BuildServiceProvider(); - - var result = await InvokeListToolsHandlerAsync( - serviceProvider, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal(["match"], result.Tools.Select(tool => tool.Name)); - } - - /// - /// Verifies that excluding SDK tools omits them from the list while keeping local tools. - /// - [Fact] - public async Task ListToolsHandler_WithoutSdkTools_OmitsSdkTools() - { - var services = CreateServices( - handlers => handlers.WithoutSdkTools(), - CreateSdkTool("sdk")); - - AddLocalTool(services, "local-key", new TestAIFunction("local")); - - using var serviceProvider = services.BuildServiceProvider(); - - var result = await InvokeListToolsHandlerAsync( - serviceProvider, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal(["local"], result.Tools.Select(tool => tool.Name)); - } - - /// - /// Verifies that a tool filtered out of the list cannot be invoked through the call handler. - /// - [Fact] - public async Task CallToolHandler_RejectsFilteredOutTool() - { - var services = CreateServices(handlers => handlers.WithToolNames("search")); - - AddLocalTool(services, "search", new TestAIFunction("search")); - AddLocalTool(services, "create", new TestAIFunction("create")); + services.Configure(configureOptions); + } - using var serviceProvider = services.BuildServiceProvider(); + builder.WithCrestAppsHandlers(configure); - await Assert.ThrowsAsync(async () => - await InvokeCallToolHandlerAsync( - serviceProvider, - "create", - TestContext.Current.CancellationToken)); + return services; } /// - /// Verifies that a tool that passes the filter can be invoked through the call handler. + /// Registers a fake tool instance catalog returning the supplied instances. /// - [Fact] - public async Task CallToolHandler_InvokesAllowedTool() + /// The service collection. + /// The instances to return. + private static void AddToolInstances(IServiceCollection services, params AIToolInstance[] instances) { - var services = CreateServices(handlers => handlers.WithToolNames("search")); - - AddLocalTool(services, "search", new TestAIFunction("search")); - - using var serviceProvider = services.BuildServiceProvider(); - - var result = await InvokeCallToolHandlerAsync( - serviceProvider, - "search", - TestContext.Current.CancellationToken); + var catalog = new Mock>(); + catalog + .Setup(value => value.GetAllAsync(It.IsAny())) + .ReturnsAsync(instances); - Assert.NotNull(result); - } - - /// - /// Creates the MCP service collection and registers the CrestApps handlers. - /// - /// The SDK tools to register in enumeration order. - /// The configured service collection. - private static ServiceCollection CreateServices(params McpServerTool[] sdkTools) - { - return CreateServices(configure: null, sdkTools); + services.AddSingleton(catalog.Object); } /// - /// Creates the MCP service collection and registers the CrestApps handlers with a configuration delegate. + /// Registers a keyed tool instance source that produces a function named after the instance. /// - /// The handler configuration delegate. - /// The SDK tools to register in enumeration order. - /// The configured service collection. - private static ServiceCollection CreateServices( - Action configure, - params McpServerTool[] sdkTools) + /// The service collection. + /// The registered source name. + private static void AddToolInstanceSource(IServiceCollection services, string sourceName) { - var services = new ServiceCollection(); - var builder = services.AddMcpServer(); - - services.AddOptions(); - builder.WithTools(sdkTools); - builder.WithCrestAppsHandlers(configure); - - return services; + services.AddKeyedSingleton(sourceName, (_, _) => new TestToolInstanceSource()); } /// @@ -576,33 +472,6 @@ private static void AddLocalTool( services.AddKeyedSingleton(registrationName, tool); } - /// - /// Registers a local tool definition with custom metadata and a keyed tool instance. - /// - /// The service collection. - /// The keyed registration name. - /// The local AI function. - /// A delegate that configures the tool definition entry. - private static void AddLocalTool( - IServiceCollection services, - string registrationName, - AIFunction tool, - Action configureEntry) - { - services.Configure(options => - { - var entry = new AIToolDefinitionEntry(typeof(TestAIFunction)) - { - Name = registrationName, - }; - - configureEntry?.Invoke(entry); - options.SetTool(registrationName, entry); - }); - - services.AddKeyedSingleton(registrationName, tool); - } - /// /// Registers a local tool definition without registering its keyed implementation. /// @@ -697,47 +566,17 @@ private static async ValueTask InvokeCallToolHandlerAsync( return await handler(request, cancellationToken); } - /// - /// The tool name. - /// The optional description. - /// The SDK MCP tool. - private static McpServerTool CreateSdkTool(string name, string description = null) - { - return McpServerTool.Create( - (Func)(static () => string.Empty), - new McpServerToolCreateOptions - { - Name = name, - Description = description, - }); - } - private sealed class NullSdkEnumerableServiceProvider : IServiceProvider + private sealed class TestToolInstanceSource : IAIToolInstanceSource { - private readonly IServiceProvider _serviceProvider; - - /// - /// Initializes a provider that masks the SDK tool enumerable. - /// - /// The underlying service provider. - public NullSdkEnumerableServiceProvider(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider; - } - /// - /// Resolves a service, returning null for the SDK tool enumerable. + /// Creates a function whose name mirrors the instance name so tests can assert on it. /// - /// The service type. - /// The resolved service. - public object GetService(Type serviceType) + /// The configured instance. + /// The produced function. + public AITool CreateTool(AIToolInstance instance) { - if (serviceType == typeof(IEnumerable)) - { - return null; - } - - return _serviceProvider.GetService(serviceType); + return new TestAIFunction(instance.Name); } } From 9c31f749f431828a239f0dc7159a8d855181270b Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 10 Aug 2026 03:24:02 +0300 Subject: [PATCH 09/13] Remove superseded MCP capability-selection scaffolding The tool-instance documentation sources and the McpServerOptions allow-list are the final MCP design on this branch. The earlier capability-selection builder (CrestAppsMcpHandlerBuilder with WithoutTools/WithoutPrompts/ WithoutResources and the WithCrestAppsHandlers configuration overload) was added before that redesign, was never merged to main, and is superseded by the default deny-all allow-list. Remove it so exposure is controlled solely by McpServerOptions. - delete CrestAppsMcpHandlerBuilder and the WithCrestAppsHandlers(configure) overload; WithCrestAppsHandlers() now unconditionally registers the tool, prompt, and resource handlers while the tool list/call handlers keep enforcing the McpServerOptions allow-list - drop the unused System.Linq and Documentation usings left in the MCP ServiceCollectionExtensions - remove the WithoutTools/WithoutPrompts/WithoutResources tests and the handler-configuration parameter from the test helper - update the MCP server docs and 1.1.0 changelog to describe only the allow-list-based tool exposure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/changelog/1.1.0.md | 7 +- src/CrestApps.Core.Docs/docs/mcp/server.md | 22 -- .../CrestAppsMcpHandlerBuilder.cs | 60 ---- .../McpServerBuilderExtensions.cs | 286 ++++++++---------- .../ServiceCollectionExtensions.cs | 2 - .../Mcp/McpServerBuilderExtensionsTests.cs | 60 +--- 6 files changed, 125 insertions(+), 312 deletions(-) delete mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index 66f9cf6e..fbf8ab2f 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -19,13 +19,8 @@ page will be updated as changes land after 1.0.0. - upgrades the framework's dependency baseline, including YesSql 6.0 (new `ISession.SaveAsync` signature), the Model Context Protocol 2.0 packages, the GitHub Copilot SDK 1.0.8 (new `PermissionsApi.SetAllowAllAsync` mode-based API), Anthropic 12.39.0, OllamaSharp 5.4.30, the .NET 10.0.10 runtime/extension packages, and the `Microsoft.Extensions.AI` 10.8.3 packages -## MCP server capability and tool exposure +## MCP server tool exposure -- adds an optional configuration delegate to `WithCrestAppsHandlers(...)` so an MCP server host can - choose which capabilities to register. The `CrestAppsMcpHandlerBuilder` provides `WithoutTools()`, - `WithoutPrompts()`, and `WithoutResources()`, making it possible to expose a read-only knowledge-base - server (prompts and resources only) or any subset of capabilities. This change is additive: the - parameterless `WithCrestAppsHandlers()` still registers every capability - reworks tool exposure into an opt-in allow-list driven by the `McpServerOptions` site settings. Nothing is exposed by default: an MCP server lists and invokes only the tools and configured [tool instances](../core/tool-instances.md) named in `McpServerOptions.Tools`, or every non-hidden tool diff --git a/src/CrestApps.Core.Docs/docs/mcp/server.md b/src/CrestApps.Core.Docs/docs/mcp/server.md index d63c2eaf..05d5721d 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/server.md +++ b/src/CrestApps.Core.Docs/docs/mcp/server.md @@ -321,28 +321,6 @@ Because `McpServerOptions` is backed by site settings, an operator can choose wh Both code-registered tools (from `AddCoreAITool()`, see [Custom Tools](../core/tools.md)) and stored tool instances (from any registered [tool instance source](../core/tool-instances.md), such as the documentation search sources) participate in the same allow-list. -### Selecting which capabilities to expose - -`WithCrestAppsHandlers()` accepts an optional configuration delegate so a host can choose which capabilities (tools, prompts, resources) are wired in. With no delegate, every capability is registered. - -```csharp -// Read-only knowledge-base server: prompts + resources, no tools -_ = builder.Services.AddMcpServer() - .WithHttpTransport() - .WithCrestAppsHandlers(handlers => handlers.WithoutTools()); -``` - -The `CrestAppsMcpHandlerBuilder` exposes: - -| Method | Effect | -|--------|--------| -| `WithoutTools()` | Does not register the tool list/call handlers, so the server exposes no tools. | -| `WithoutPrompts()` | Does not register the prompt handlers. | -| `WithoutResources()` | Does not register the resource handlers. | - -Capability registration (which handlers exist) is chosen in code, while tool exposure (which tools those handlers surface) is chosen by the `McpServerOptions` allow-list. - - ## Server Metadata ### IMcpServerMetadataProvider diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs b/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs deleted file mode 100644 index db848dbc..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/CrestAppsMcpHandlerBuilder.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace CrestApps.Core.AI.Mcp; - -/// -/// Configures which CrestApps MCP protocol handlers are wired into an MCP server. By default every -/// capability (tools, prompts, and resources) is registered. Use the fluent methods to opt out of a -/// capability, for example to expose a read-only knowledgebase server that only serves prompts and -/// resources. Which tools and tool instances are actually listed and callable is controlled separately -/// by the site settings allow-list. -/// -public sealed class CrestAppsMcpHandlerBuilder -{ - /// - /// Gets a value indicating whether the tool list and call handlers are registered. - /// - public bool IncludeTools { get; private set; } = true; - - /// - /// Gets a value indicating whether the prompt list and get handlers are registered. - /// - public bool IncludePrompts { get; private set; } = true; - - /// - /// Gets a value indicating whether the resource list, template list, and read handlers are registered. - /// - public bool IncludeResources { get; private set; } = true; - - /// - /// Excludes the tool handlers so the server does not list or invoke any tools. Use this to expose - /// a read-only knowledgebase server that only serves prompts and resources. - /// - /// The same builder instance for chaining. - public CrestAppsMcpHandlerBuilder WithoutTools() - { - IncludeTools = false; - - return this; - } - - /// - /// Excludes the prompt handlers so the server does not list or serve prompts. - /// - /// The same builder instance for chaining. - public CrestAppsMcpHandlerBuilder WithoutPrompts() - { - IncludePrompts = false; - - return this; - } - - /// - /// Excludes the resource handlers so the server does not list, template, or read resources. - /// - /// The same builder instance for chaining. - public CrestAppsMcpHandlerBuilder WithoutResources() - { - IncludeResources = false; - - return this; - } -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs index bc2053ab..3683f4f8 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs @@ -22,62 +22,81 @@ public static class McpServerBuilderExtensions /// This wires the CrestApps tool registry (), /// , and /// into the MCP protocol so both Orchard Core and standalone MVC hosts share the same handler logic. + /// Which tools and tool instances are actually listed and callable is controlled by the + /// site settings allow-list. /// /// The builder. public static IMcpServerBuilder WithCrestAppsHandlers(this IMcpServerBuilder builder) - { - return builder.WithCrestAppsHandlers(configure: null); - } - - /// - /// Registers the CrestApps MCP server handlers, letting the caller choose which capabilities - /// (tools, prompts, and resources) are registered. When is - /// every capability is registered. Which tools and tool instances are actually - /// listed and callable is controlled by the site settings allow-list. - /// - /// The builder. - /// A delegate that configures the registered capabilities. - public static IMcpServerBuilder WithCrestAppsHandlers( - this IMcpServerBuilder builder, - Action configure) { ArgumentNullException.ThrowIfNull(builder); - var handlerBuilder = new CrestAppsMcpHandlerBuilder(); - configure?.Invoke(handlerBuilder); + return builder + .WithListToolsHandler(async (request, cancellationToken) => + { + var serverOptions = request.Services.GetRequiredService>().CurrentValue; + var exposeAll = serverOptions.ExposeAllTools; + var allowList = BuildAllowList(serverOptions.Tools); + var toolDefinitions = request.Services.GetRequiredService>().Value; + ILogger logger = null; + var tools = new List(); + var seenNames = new HashSet(StringComparer.Ordinal); + + foreach (var (name, definition) in toolDefinitions.Tools) + { + if (definition.Hidden || !IsAllowed(exposeAll, allowList, name, definition.Name)) + { + continue; + } - return builder.WithCrestAppsHandlers(handlerBuilder); - } + try + { + if (request.Services.GetKeyedService(name) is AIFunction aiFunction && seenNames.Add(aiFunction.Name)) + { + tools.Add(new Tool + { + Name = aiFunction.Name, + Description = aiFunction.Description, + InputSchema = aiFunction.JsonSchema, + }); + } + } + catch (Exception ex) + { + logger ??= request.Services.GetRequiredService>(); + logger.LogError(ex, "Error creating tool instance for '{ToolName}'.", name); + } + } - private static IMcpServerBuilder WithCrestAppsHandlers( - this IMcpServerBuilder builder, - CrestAppsMcpHandlerBuilder handlerBuilder) - { - ArgumentNullException.ThrowIfNull(builder); + var instanceCatalog = request.Services.GetService>(); - if (handlerBuilder.IncludeTools) - { - builder - .WithListToolsHandler(async (request, cancellationToken) => + if (instanceCatalog is not null) { - var serverOptions = request.Services.GetRequiredService>().CurrentValue; - var exposeAll = serverOptions.ExposeAllTools; - var allowList = BuildAllowList(serverOptions.Tools); - var toolDefinitions = request.Services.GetRequiredService>().Value; - ILogger logger = null; - var tools = new List(); - var seenNames = new HashSet(StringComparer.Ordinal); - - foreach (var (name, definition) in toolDefinitions.Tools) + var instances = await instanceCatalog.GetAllAsync(cancellationToken); + + foreach (var instance in instances) { - if (definition.Hidden || !IsAllowed(exposeAll, allowList, name, definition.Name)) + if (string.IsNullOrEmpty(instance.Source)) + { + continue; + } + + var functionName = instance.GetFunctionName(); + + if (!IsAllowed(exposeAll, allowList, functionName, instance.Name)) + { + continue; + } + + var source = request.Services.GetKeyedService(instance.Source); + + if (source is null) { continue; } try { - if (request.Services.GetKeyedService(name) is AIFunction aiFunction && seenNames.Add(aiFunction.Name)) + if (source.CreateTool(instance) is AIFunction aiFunction && seenNames.Add(aiFunction.Name)) { tools.Add(new Tool { @@ -90,157 +109,98 @@ private static IMcpServerBuilder WithCrestAppsHandlers( catch (Exception ex) { logger ??= request.Services.GetRequiredService>(); - logger.LogError(ex, "Error creating tool instance for '{ToolName}'.", name); + logger.LogError(ex, "Error creating tool for instance '{InstanceName}'.", instance.Name); } } + } - var instanceCatalog = request.Services.GetService>(); - - if (instanceCatalog is not null) - { - var instances = await instanceCatalog.GetAllAsync(cancellationToken); - - foreach (var instance in instances) - { - if (string.IsNullOrEmpty(instance.Source)) - { - continue; - } - - var functionName = instance.GetFunctionName(); - - if (!IsAllowed(exposeAll, allowList, functionName, instance.Name)) - { - continue; - } - - var source = request.Services.GetKeyedService(instance.Source); - - if (source is null) - { - continue; - } + return new ListToolsResult { Tools = tools }; + }) + .WithCallToolHandler(async (request, cancellationToken) => + { + var serverOptions = request.Services.GetRequiredService>().CurrentValue; + var exposeAll = serverOptions.ExposeAllTools; + var allowList = BuildAllowList(serverOptions.Tools); + var toolDefinitions = request.Services.GetRequiredService>().Value; - try - { - if (source.CreateTool(instance) is AIFunction aiFunction && seenNames.Add(aiFunction.Name)) - { - tools.Add(new Tool - { - Name = aiFunction.Name, - Description = aiFunction.Description, - InputSchema = aiFunction.JsonSchema, - }); - } - } - catch (Exception ex) - { - logger ??= request.Services.GetRequiredService>(); - logger.LogError(ex, "Error creating tool for instance '{InstanceName}'.", instance.Name); - } - } - } + var logger = request.Services.GetService>(); + var codeTool = ResolveAllowedCodeTool(request.Services, toolDefinitions, exposeAll, allowList, request.Params.Name, logger); - return new ListToolsResult { Tools = tools }; - }) - .WithCallToolHandler(async (request, cancellationToken) => + if (codeTool is not null) { - var serverOptions = request.Services.GetRequiredService>().CurrentValue; - var exposeAll = serverOptions.ExposeAllTools; - var allowList = BuildAllowList(serverOptions.Tools); - var toolDefinitions = request.Services.GetRequiredService>().Value; - - var logger = request.Services.GetService>(); - var codeTool = ResolveAllowedCodeTool(request.Services, toolDefinitions, exposeAll, allowList, request.Params.Name, logger); + var result = await codeTool.InvokeAsync(BuildArguments(request), cancellationToken); - if (codeTool is not null) + return new CallToolResult { - var result = await codeTool.InvokeAsync(BuildArguments(request), cancellationToken); + Content = [new TextContentBlock { Text = result?.ToString() ?? string.Empty }], + }; + } - return new CallToolResult - { - Content = [new TextContentBlock { Text = result?.ToString() ?? string.Empty }], - }; - } + var instanceCatalog = request.Services.GetService>(); - var instanceCatalog = request.Services.GetService>(); + if (instanceCatalog is not null) + { + var instance = await ResolveInstanceAsync(instanceCatalog, request.Params.Name, cancellationToken); - if (instanceCatalog is not null) + if (instance is not null && + !string.IsNullOrEmpty(instance.Source) && + IsAllowed(exposeAll, allowList, instance.GetFunctionName(), instance.Name)) { - var instance = await ResolveInstanceAsync(instanceCatalog, request.Params.Name, cancellationToken); + var source = request.Services.GetKeyedService(instance.Source); - if (instance is not null && - !string.IsNullOrEmpty(instance.Source) && - IsAllowed(exposeAll, allowList, instance.GetFunctionName(), instance.Name)) + if (source is not null && source.CreateTool(instance) is AIFunction instanceFunction) { - var source = request.Services.GetKeyedService(instance.Source); + var result = await instanceFunction.InvokeAsync(BuildArguments(request), cancellationToken); - if (source is not null && source.CreateTool(instance) is AIFunction instanceFunction) + return new CallToolResult { - var result = await instanceFunction.InvokeAsync(BuildArguments(request), cancellationToken); - - return new CallToolResult - { - Content = [new TextContentBlock { Text = result?.ToString() ?? string.Empty }], - }; - } + Content = [new TextContentBlock { Text = result?.ToString() ?? string.Empty }], + }; } } + } - throw new McpException($"Tool '{request.Params.Name}' not found."); - }); - } - - if (handlerBuilder.IncludePrompts) - { - builder - .WithListPromptsHandler(async (request, cancellationToken) => - { - var promptService = request.Services.GetRequiredService(); + throw new McpException($"Tool '{request.Params.Name}' not found."); + }) + .WithListPromptsHandler(async (request, cancellationToken) => + { + var promptService = request.Services.GetRequiredService(); - return new ListPromptsResult - { - Prompts = await promptService.ListAsync(), - }; - }) - .WithGetPromptHandler(async (request, cancellationToken) => + return new ListPromptsResult { - var promptService = request.Services.GetRequiredService(); - - return await promptService.GetAsync(request, cancellationToken); - }); - } + Prompts = await promptService.ListAsync(), + }; + }) + .WithGetPromptHandler(async (request, cancellationToken) => + { + var promptService = request.Services.GetRequiredService(); - if (handlerBuilder.IncludeResources) - { - builder - .WithListResourcesHandler(async (request, cancellationToken) => - { - var resourceService = request.Services.GetRequiredService(); + return await promptService.GetAsync(request, cancellationToken); + }) + .WithListResourcesHandler(async (request, cancellationToken) => + { + var resourceService = request.Services.GetRequiredService(); - return new ListResourcesResult - { - Resources = await resourceService.ListAsync(), - }; - }) - .WithListResourceTemplatesHandler(async (request, cancellationToken) => + return new ListResourcesResult { - var resourceService = request.Services.GetRequiredService(); + Resources = await resourceService.ListAsync(), + }; + }) + .WithListResourceTemplatesHandler(async (request, cancellationToken) => + { + var resourceService = request.Services.GetRequiredService(); - return new ListResourceTemplatesResult - { - ResourceTemplates = await resourceService.ListTemplatesAsync(), - }; - }) - .WithReadResourceHandler(async (request, cancellationToken) => + return new ListResourceTemplatesResult { - var resourceService = request.Services.GetRequiredService(); - - return await resourceService.ReadAsync(request, cancellationToken); - }); - } + ResourceTemplates = await resourceService.ListTemplatesAsync(), + }; + }) + .WithReadResourceHandler(async (request, cancellationToken) => + { + var resourceService = request.Services.GetRequiredService(); - return builder; + return await resourceService.ReadAsync(request, cancellationToken); + }); } private static HashSet BuildAllowList(IEnumerable names) diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs index ace9228d..2df5248e 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs @@ -1,6 +1,4 @@ -using System.Linq; using CrestApps.Core.AI.Completions; -using CrestApps.Core.AI.Mcp.Documentation; using CrestApps.Core.AI.Mcp.Functions; using CrestApps.Core.AI.Mcp.Handlers; using CrestApps.Core.AI.Mcp.Models; diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs index e2399501..cbedafe1 100644 --- a/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Mcp/McpServerBuilderExtensionsTests.cs @@ -348,70 +348,12 @@ await InvokeCallToolHandlerAsync( TestContext.Current.CancellationToken)); } - /// - /// Verifies that excluding tools omits the tool handlers while keeping prompt and resource handlers. - /// - [Fact] - public void WithoutTools_OmitsToolHandlersButKeepsPromptAndResourceHandlers() - { - var services = CreateServices(handlers => handlers.WithoutTools()); - - using var serviceProvider = services.BuildServiceProvider(); - var handlers = serviceProvider.GetRequiredService>().Value.Handlers; - - Assert.Null(handlers.ListToolsHandler); - Assert.Null(handlers.CallToolHandler); - Assert.NotNull(handlers.ListPromptsHandler); - Assert.NotNull(handlers.GetPromptHandler); - Assert.NotNull(handlers.ListResourcesHandler); - Assert.NotNull(handlers.ListResourceTemplatesHandler); - Assert.NotNull(handlers.ReadResourceHandler); - } - - /// - /// Verifies that excluding prompts omits only the prompt handlers. - /// - [Fact] - public void WithoutPrompts_OmitsPromptHandlersOnly() - { - var services = CreateServices(handlers => handlers.WithoutPrompts()); - - using var serviceProvider = services.BuildServiceProvider(); - var handlers = serviceProvider.GetRequiredService>().Value.Handlers; - - Assert.Null(handlers.ListPromptsHandler); - Assert.Null(handlers.GetPromptHandler); - Assert.NotNull(handlers.ListToolsHandler); - Assert.NotNull(handlers.CallToolHandler); - Assert.NotNull(handlers.ListResourcesHandler); - } - - /// - /// Verifies that excluding resources omits only the resource handlers. - /// - [Fact] - public void WithoutResources_OmitsResourceHandlersOnly() - { - var services = CreateServices(handlers => handlers.WithoutResources()); - - using var serviceProvider = services.BuildServiceProvider(); - var handlers = serviceProvider.GetRequiredService>().Value.Handlers; - - Assert.Null(handlers.ListResourcesHandler); - Assert.Null(handlers.ListResourceTemplatesHandler); - Assert.Null(handlers.ReadResourceHandler); - Assert.NotNull(handlers.ListToolsHandler); - Assert.NotNull(handlers.ListPromptsHandler); - } - /// /// Creates the MCP service collection and registers the CrestApps handlers. /// - /// An optional handler configuration delegate. /// An optional delegate that configures the exposure settings. /// The configured service collection. private static ServiceCollection CreateServices( - Action configure = null, Action configureOptions = null) { var services = new ServiceCollection(); @@ -425,7 +367,7 @@ private static ServiceCollection CreateServices( services.Configure(configureOptions); } - builder.WithCrestAppsHandlers(configure); + builder.WithCrestAppsHandlers(); return services; } From 17abb496923b90915e0684bc6936f99381687c78 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 10 Aug 2026 07:08:16 +0300 Subject: [PATCH 10/13] Address PR review: relocate doc sources, JSON options, exposure docs Responds to Mike Alhayek's review on PR #125. - move the documentation search tool-instance stack out of the MCP project (CrestApps.Core.AI.Mcp) into CrestApps.Core.AI under Tooling/Instances/Documentation. These are ordinary tool instance sources usable without the MCP server, so they no longer live in or depend on the MCP package. The doc HTTP client name moves to DocumentationToolConstants and the plain AddHttpClient registration matches the sibling HttpApiRequest source (no MCP-only resilience dependency) - replace per-property [JsonPropertyName] attributes in the Algolia and search-index sources with a JsonSerializerOptions (web defaults / camelCase) passed to the serialize and deserialize calls - skip building the allow-list in the MCP list and call handlers when ExposeAllTools is true - add a Breaking Changes note to the 1.1.0 changelog: MCP servers no longer expose tools by default (behavior change from 1.0.0) - reword the changelog so the documentation sources are described as tool instance sources (not a replacement for a never-shipped search_documentation tool, and not part of the MCP server builder) - fold the standalone documentation-search page into the MCP server feature page and remove it from the sidebar - clarify in the MCP server docs that ExposeAllTools ignores the Tools list - relocate the documentation search tests to Core/Tools to match the move Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/changelog/1.1.0.md | 31 ++- .../docs/mcp/documentation-search.md | 182 ------------------ src/CrestApps.Core.Docs/docs/mcp/server.md | 80 +++++++- src/CrestApps.Core.Docs/sidebars.js | 1 - .../CrestApps.Core.AI.Mcp/McpConstants.cs | 5 - .../McpServerBuilderExtensions.cs | 4 +- .../Documentation/AlgoliaDocSearchSite.cs | 2 +- .../AlgoliaDocumentationSource.cs | 24 +-- .../AlgoliaDocumentationToolSettings.cs | 2 +- .../AlgoliaDocumentationToolSource.cs | 2 +- .../CachingDocumentationSource.cs | 2 +- .../DefaultDocumentationSourceMaterializer.cs | 2 +- .../Documentation/DocumentationCorpus.cs | 2 +- .../DocumentationSearchIndexSite.cs | 2 +- .../DocumentationSearchOptions.cs | 2 +- .../DocumentationSearchRequest.cs | 2 +- .../DocumentationSearchResult.cs | 2 +- .../DocumentationSearchToolFunction.cs | 2 +- .../Documentation/DocumentationSite.cs | 2 +- .../DocumentationToolConstants.cs | 7 +- ...ToolInstanceServiceCollectionExtensions.cs | 6 +- .../Documentation/IDocumentationSource.cs | 2 +- .../IDocumentationSourceMaterializer.cs | 2 +- .../SearchIndexDocumentationSource.cs | 14 +- .../SearchIndexDocumentationToolSettings.cs | 2 +- .../SearchIndexDocumentationToolSource.cs | 2 +- .../SitemapDocumentationSource.cs | 4 +- .../SitemapDocumentationToolSettings.cs | 2 +- .../SitemapDocumentationToolSource.cs | 2 +- .../Pages/Tooling/ToolInstances/Create.razor | 2 +- .../Pages/Tooling/ToolInstances/Edit.razor | 2 +- .../CrestApps.Core.Blazor.Web/Program.cs | 2 +- .../Controllers/AIToolInstanceController.cs | 2 +- src/Startup/CrestApps.Core.Mvc.Web/Program.cs | 2 +- .../DocumentationSearchTests.cs | 4 +- 35 files changed, 150 insertions(+), 258 deletions(-) delete mode 100644 src/CrestApps.Core.Docs/docs/mcp/documentation-search.md rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/AlgoliaDocSearchSite.cs (95%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/AlgoliaDocumentationSource.cs (88%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/AlgoliaDocumentationToolSettings.cs (94%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/AlgoliaDocumentationToolSource.cs (97%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/CachingDocumentationSource.cs (98%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/DefaultDocumentationSourceMaterializer.cs (95%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/DocumentationCorpus.cs (98%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/DocumentationSearchIndexSite.cs (95%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/DocumentationSearchOptions.cs (94%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/DocumentationSearchRequest.cs (92%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/DocumentationSearchResult.cs (94%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/DocumentationSearchToolFunction.cs (99%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/DocumentationSite.cs (96%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/DocumentationToolConstants.cs (82%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/DocumentationToolInstanceServiceCollectionExtensions.cs (96%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/IDocumentationSource.cs (94%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/IDocumentationSourceMaterializer.cs (94%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/SearchIndexDocumentationSource.cs (91%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/SearchIndexDocumentationToolSettings.cs (95%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/SearchIndexDocumentationToolSource.cs (97%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/SitemapDocumentationSource.cs (97%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/SitemapDocumentationToolSettings.cs (95%) rename src/Primitives/{CrestApps.Core.AI.Mcp => CrestApps.Core.AI/Tooling/Instances}/Documentation/SitemapDocumentationToolSource.cs (97%) rename tests/CrestApps.Core.Tests/Core/{Mcp => Tools}/DocumentationSearchTests.cs (98%) diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index fbf8ab2f..233e05f5 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -19,6 +19,16 @@ page will be updated as changes land after 1.0.0. - upgrades the framework's dependency baseline, including YesSql 6.0 (new `ISession.SaveAsync` signature), the Model Context Protocol 2.0 packages, the GitHub Copilot SDK 1.0.8 (new `PermissionsApi.SetAllowAllAsync` mode-based API), Anthropic 12.39.0, OllamaSharp 5.4.30, the .NET 10.0.10 runtime/extension packages, and the `Microsoft.Extensions.AI` 10.8.3 packages +## Breaking Changes + +- **MCP servers no longer expose tools by default.** In 1.0.0 a server registered with + `WithCrestAppsHandlers()` listed and invoked every non-hidden registered tool. In 1.1.0 tool exposure is + opt-in through the new `McpServerOptions` site settings: nothing is listed or callable until you either + add the tool or tool instance name to `McpServerOptions.Tools` or set `McpServerOptions.ExposeAllTools` + to `true`. Hosts upgrading from 1.0.0 that relied on the previous "expose everything" behavior must set + `ExposeAllTools = true` (or populate `Tools`) to keep exposing their tools. Prompts and resources are + unaffected and are still always registered + ## MCP server tool exposure - reworks tool exposure into an opt-in allow-list driven by the `McpServerOptions` site settings. Nothing @@ -32,16 +42,17 @@ page will be updated as changes land after 1.0.0. ## Documentation search tool instances -- replaces the earlier single `search_documentation` tool with documentation search - [tool instance sources](../core/tool-instances.md), so a host exposes one callable search function per - documentation site it configures. Register the sources on the tool instances builder with - `AddDocumentationSearchSources()` (or the individual `AddSitemapDocumentationSource()`, - `AddSearchIndexDocumentationSource()`, and `AddAlgoliaDocumentationSource()` methods). Each configured - instance binds one site and surfaces as a distinct function the AI model can call, and the instances are - managed and persisted through the existing tool instance store (YesSql or Entity Framework Core) and UI. - The MVC and Blazor sample-host tool instance editors add source-specific field groups so operators can - configure a documentation site (base URL, sitemap or index URL, Algolia application/index/search-only - key, and per-instance result limits) directly from the create and edit forms +- adds documentation search [tool instance sources](../core/tool-instances.md), so a host exposes one + callable search function per documentation site it configures. These are ordinary tool instance sources + registered on the tool instances builder with `AddDocumentationSearchSources()` (or the individual + `AddSitemapDocumentationSource()`, `AddSearchIndexDocumentationSource()`, and + `AddAlgoliaDocumentationSource()` methods), so they can be used with or without the MCP server. Each + configured instance binds one site and surfaces as a distinct function the AI model can call, and the + instances are managed and persisted through the existing tool instance store (YesSql or Entity Framework + Core) and UI. The MVC and Blazor sample-host tool instance editors add source-specific field groups so + operators can configure a documentation site (base URL, sitemap or index URL, Algolia + application/index/search-only key, and per-instance result limits) directly from the create and edit + forms - ships three documentation search strategies, each as its own source and settings model: the sitemap source crawls a site through its `sitemap.xml` (for example a public Docusaurus site such as `core.crestapps.com`), the search-index source downloads a prebuilt JSON search index (for example a diff --git a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md b/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md deleted file mode 100644 index 37ade291..00000000 --- a/src/CrestApps.Core.Docs/docs/mcp/documentation-search.md +++ /dev/null @@ -1,182 +0,0 @@ ---- -sidebar_label: Documentation Search -sidebar_position: 5 -title: Documentation Search -description: Register tool instance sources that search public documentation sites as a knowledge base. ---- - -# Documentation Search - -> Register documentation search [tool instance](../core/tool-instances.md) sources so operators can configure one callable search function per documentation site (such as Docusaurus or MkDocs) and expose them through an MCP server. - -## Problem & Solution - -A knowledge-base MCP server often needs to answer questions from product or framework documentation -that lives on public sites. Instead of indexing that content into a vector store, the documentation -search sources let an operator declare a documentation site as a **tool instance** and scan it on -demand. - -Rather than a single fixed tool, documentation search ships as [tool instance sources](../core/tool-instances.md): -developer-authored blueprints that a user configures one or more times. Each configured instance binds -one site and surfaces as its own callable function, so a host can offer "search the CrestApps docs" and -"search the Orchard Core docs" as two distinct tools. Instances are persisted and managed through the -standard tool instance store and UI, and are exposed to MCP clients through the server's -[allow-list](./server.md#tool-exposure) — nothing is exposed until you opt in. - -## Quick Start - -Register the documentation search sources on the tool instances builder: - -```csharp -builder.Services.AddCrestAppsCore(crestApps => crestApps - .AddAISuite(ai => ai - .AddOpenAI() - .AddToolInstances(toolInstances => toolInstances - .AddDocumentationSearchSources() - .AddYesSqlStores() - ) - .AddMcpServer(mcpServer => mcpServer - .AddYesSqlStores() - ) - ) - .AddYesSqlDataStore(configuration => configuration - .UseSqLite("Data Source=app.db;Cache=Shared") - ) -); -``` - -`AddDocumentationSearchSources()` registers all three built-in sources. To register only the ones you -need, call the individual methods instead: - -```csharp -.AddToolInstances(toolInstances => toolInstances - .AddSitemapDocumentationSource() - .AddSearchIndexDocumentationSource() - .AddAlgoliaDocumentationSource()) -``` - -Once the sources are registered, operators create configured instances (each bound to one site) through -the tool instances UI or store. Each instance becomes a callable function the AI model can invoke. - -## Search Strategies - -A documentation site can be indexed in different ways depending on what the generator publishes. Each -strategy is its own source with its own settings model, so you pick the one that matches the site. - -| Source | Registration | Best for | How it works | -|--------|--------------|----------|--------------| -| Sitemap crawl | `AddSitemapDocumentationSource()` | Any site that publishes `sitemap.xml` (Docusaurus, MkDocs, and most static sites). | Crawls pages, strips HTML, and ranks locally with keyword scoring. | -| Search index | `AddSearchIndexDocumentationSource()` | MkDocs Material and other sites that publish a fetchable `search_index.json`. | Downloads the prebuilt index once and ranks its entries locally. | -| Algolia DocSearch | `AddAlgoliaDocumentationSource()` | Docusaurus sites (and others) wired to hosted Algolia DocSearch. | Forwards the query to Algolia, which performs the ranking. | - -The registered source names are defined by `DocumentationToolConstants` -(`sitemap-documentation`, `search-index-documentation`, `algolia-documentation`), and all three carry -the `Knowledgebase` category. - -### Sitemap crawl settings - -`SitemapDocumentationToolSettings` binds a sitemap-crawl instance: - -| Property | Description | -|----------|-------------| -| `BaseUrl` | Base URL of the documentation site (for example `https://core.crestapps.com`). | -| `SitemapUrl` | Optional explicit sitemap URL. Defaults to `{BaseUrl}/sitemap.xml`. | -| `MaxResults` | Optional maximum results this instance returns per search. | -| `MaxPages` | Optional maximum pages the crawler indexes for this site. | - -### Search index settings - -`SearchIndexDocumentationToolSettings` binds a search-index instance: - -| Property | Description | -|----------|-------------| -| `BaseUrl` | Base URL used to resolve relative entry locations and the default index URL. | -| `IndexUrl` | Optional explicit index URL. Defaults to `{BaseUrl}/search/search_index.json`. | -| `MaxResults` | Optional maximum results this instance returns per search. | - -:::note -This targets the MkDocs Material `search_index.json` schema (`{ "docs": [ { "location", "title", "text" } ] }`). -Docusaurus' `@easyops-cn/docusaurus-search-local` plugin stores a client-side Lunr index that is not a -cleanly fetchable JSON document, so use the sitemap crawl or Algolia DocSearch for Docusaurus sites. -::: - -### Algolia DocSearch settings - -`AlgoliaDocumentationToolSettings` binds an Algolia DocSearch instance: - -| Property | Description | -|----------|-------------| -| `ApplicationId` | Algolia application identifier. | -| `ApiKey` | Algolia **search-only** API key (never a write key). | -| `IndexName` | Algolia index name to query. | -| `MaxResults` | Optional maximum results this instance returns per search. | - -## Example: a public Docusaurus site - -A public Docusaurus site that requires no authentication — such as -[core.crestapps.com](https://core.crestapps.com) — only needs the sitemap crawl source. Docusaurus -publishes a standard `sitemap.xml` at the site root, so the crawler discovers `{BaseUrl}/sitemap.xml` -automatically. - -1. Register the sitemap source (or all sources) as shown in [Quick Start](#quick-start). -2. Create a tool instance from the **Documentation search (sitemap)** source with: - - **Name**: `crestapps-docs` (this is the name you expose to MCP clients) - - **Description**: a clear sentence such as *"Searches the CrestApps.Core documentation."* - - **Base URL**: `https://core.crestapps.com` -3. Expose the instance through the MCP server by adding its name to the allow-list: - -```csharp -services.Configure(options => -{ - options.Tools = ["crestapps-docs"]; -}); -``` - -Because the site is public, no headers, API keys, or credentials are involved — the crawler issues -plain anonymous `GET` requests through a resilient `HttpClient`. The first search crawls the site and -caches the corpus; later searches reuse the cache. - -:::tip -Prefer the sitemap crawl for a public Docusaurus site. Only reach for the Algolia source when the site -is wired to hosted Algolia DocSearch and you have its application ID, search-only API key, and index -name. -::: - -## Exposing documentation search through MCP - -Documentation search functions are exposed like any other tool instance. Add the instance name to -`McpServerOptions.Tools`, or set `McpServerOptions.ExposeAllTools = true` to expose every non-hidden -tool and instance. Because `McpServerOptions` is backed by site settings, operators can manage the -allow-list from the admin **Settings → MCP server** page. See -[MCP Server → Tool Exposure](./server.md#tool-exposure) for details. - -## Corpus caching - -The runtime documentation source (the crawled corpus or downloaded index) is built lazily and cached by -a singleton `IDocumentationSourceMaterializer`, keyed by the instance identifier. The cache is rebuilt -only when the instance changes, so an edit to a site's settings is picked up on the next search while an -unchanged instance reuses its corpus across calls. - -## Adding a new documentation source - -To support a documentation site that none of the built-in strategies cover, implement a new -[tool instance source](../core/tool-instances.md): - -1. Create a settings model for the user-provided configuration. -2. Implement `IAIToolInstanceSource.CreateTool(AIToolInstance)` to read the settings and return a - `DocumentationSearchToolFunction` (or your own `AIFunction`) bound to a concrete `IDocumentationSource`. -3. Register the source on the tool instances builder with `AddSource(name, configure)`. - -Operators then create instances of your new source exactly like the built-in ones, and expose them -through the same MCP allow-list. - -## How It Works - -1. `AddDocumentationSearchSources()` registers the three tool instance sources, a singleton - `IDocumentationSourceMaterializer`, and a named `HttpClient` with standard resilience. -2. An operator configures one or more `AIToolInstance` entries, each bound to a single site through its - settings. -3. When the MCP server lists or calls tools, allow-listed instances are materialized through their - keyed `IAIToolInstanceSource`, which produces a `DocumentationSearchToolFunction`. -4. On invocation, the function resolves (and caches) the concrete `IDocumentationSource`, searches it, - and returns the ranked results with their titles and URLs so the model can cite them. diff --git a/src/CrestApps.Core.Docs/docs/mcp/server.md b/src/CrestApps.Core.Docs/docs/mcp/server.md index 05d5721d..f409b25a 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/server.md +++ b/src/CrestApps.Core.Docs/docs/mcp/server.md @@ -308,6 +308,7 @@ services.Configure(options => options.Tools = ["crestapps-docs", "weather"]; // Or expose every non-hidden tool and tool instance. + // When set to true, the Tools allow-list above is ignored. options.ExposeAllTools = true; }); ``` @@ -319,7 +320,84 @@ services.Configure(options => Because `McpServerOptions` is backed by site settings, an operator can choose which tools to expose from the admin **Settings → MCP server** page without redeploying. The allow-list is enforced by **both** the list and call handlers, so a tool that is not exposed can neither be discovered nor invoked. `.Hidden()` tools are always excluded, even when `ExposeAllTools` is `true`. -Both code-registered tools (from `AddCoreAITool()`, see [Custom Tools](../core/tools.md)) and stored tool instances (from any registered [tool instance source](../core/tool-instances.md), such as the documentation search sources) participate in the same allow-list. +Both code-registered tools (from `AddCoreAITool()`, see [Custom Tools](../core/tools.md)) and stored tool instances (from any registered [tool instance source](../core/tool-instances.md), such as the [documentation search sources](#exposing-a-documentation-knowledge-base)) participate in the same allow-list. + +## Exposing a documentation knowledge base + +A common reason to run an MCP server is to answer questions from product or framework documentation that lives on a public site (such as a Docusaurus or MkDocs site). Instead of indexing that content into a vector store, the built-in documentation search [tool instance sources](../core/tool-instances.md) let an operator declare a documentation site as a **tool instance** and scan it on demand. + +These sources are ordinary tool instance sources registered on the tool instances builder, so they are usable with or without the MCP server. Each configured instance binds one site and surfaces as its own callable function, so a host can offer "search the CrestApps docs" and "search the Orchard Core docs" as two distinct tools. Instances are persisted and managed through the standard tool instance store and UI, and are exposed to MCP clients through the [allow-list](#tool-exposure) above — nothing is exposed until you opt in. + +### Registering the sources + +```csharp +builder.Services.AddCrestAppsCore(crestApps => crestApps + .AddAISuite(ai => ai + .AddOpenAI() + .AddToolInstances(toolInstances => toolInstances + .AddDocumentationSearchSources() + .AddYesSqlStores() + ) + .AddMcpServer(mcpServer => mcpServer + .AddYesSqlStores() + ) + ) + .AddYesSqlDataStore(configuration => configuration + .UseSqLite("Data Source=app.db;Cache=Shared") + ) +); +``` + +`AddDocumentationSearchSources()` registers all three built-in sources. To register only the ones you need, call the individual `AddSitemapDocumentationSource()`, `AddSearchIndexDocumentationSource()`, and `AddAlgoliaDocumentationSource()` methods instead. Once the sources are registered, operators create configured instances (each bound to one site) through the tool instances UI or store, and each instance becomes a callable function the AI model can invoke. + +### Search strategies + +A documentation site can be indexed in different ways depending on what the generator publishes. Each strategy is its own source with its own settings model, so you pick the one that matches the site. + +| Source | Registration | Best for | How it works | +|--------|--------------|----------|--------------| +| Sitemap crawl | `AddSitemapDocumentationSource()` | Any site that publishes `sitemap.xml` (Docusaurus, MkDocs, and most static sites). | Crawls pages, strips HTML, and ranks locally with keyword scoring. | +| Search index | `AddSearchIndexDocumentationSource()` | MkDocs Material and other sites that publish a fetchable `search_index.json`. | Downloads the prebuilt index once and ranks its entries locally. | +| Algolia DocSearch | `AddAlgoliaDocumentationSource()` | Docusaurus sites (and others) wired to hosted Algolia DocSearch. | Forwards the query to Algolia, which performs the ranking. | + +The registered source names are defined by `DocumentationToolConstants` (`sitemap-documentation`, `search-index-documentation`, `algolia-documentation`), and all three carry the `Knowledgebase` category. + +Each strategy binds a settings model: + +- **`SitemapDocumentationToolSettings`** — `BaseUrl` (site root, for example `https://core.crestapps.com`), optional `SitemapUrl` (defaults to `{BaseUrl}/sitemap.xml`), optional `MaxResults`, and optional `MaxPages`. +- **`SearchIndexDocumentationToolSettings`** — `BaseUrl` (used to resolve relative locations and the default index URL), optional `IndexUrl` (defaults to `{BaseUrl}/search/search_index.json`), and optional `MaxResults`. This targets the MkDocs Material `search_index.json` schema (`{ "docs": [ { "location", "title", "text" } ] }`). +- **`AlgoliaDocumentationToolSettings`** — `ApplicationId`, `ApiKey` (Algolia **search-only** key, never a write key), `IndexName`, and optional `MaxResults`. + +### Example: a public Docusaurus site + +A public Docusaurus site that requires no authentication — such as [core.crestapps.com](https://core.crestapps.com) — only needs the sitemap crawl source. Docusaurus publishes a standard `sitemap.xml` at the site root, so the crawler discovers `{BaseUrl}/sitemap.xml` automatically. + +1. Register the sitemap source (or all sources) as shown above. +2. Create a tool instance from the **Documentation search (sitemap)** source with a **Name** (for example `crestapps-docs`, the name you expose to MCP clients), a clear **Description**, and a **Base URL** of `https://core.crestapps.com`. +3. Expose the instance through the MCP server by adding its name to the allow-list: + +```csharp +services.Configure(options => +{ + options.Tools = ["crestapps-docs"]; +}); +``` + +Because the site is public, no headers, API keys, or credentials are involved — the crawler issues plain anonymous `GET` requests. The first search crawls the site and caches the corpus; later searches reuse the cache. + +### Corpus caching + +The runtime documentation source (the crawled corpus or downloaded index) is built lazily and cached by a singleton `IDocumentationSourceMaterializer`, keyed by the instance identifier. The cache is rebuilt only when the instance changes, so an edit to a site's settings is picked up on the next search while an unchanged instance reuses its corpus across calls. + +### Adding a new documentation source + +To support a documentation site that none of the built-in strategies cover, implement a new [tool instance source](../core/tool-instances.md): + +1. Create a settings model for the user-provided configuration. +2. Implement `IAIToolInstanceSource.CreateTool(AIToolInstance)` to read the settings and return a `DocumentationSearchToolFunction` (or your own `AIFunction`) bound to a concrete `IDocumentationSource`. +3. Register the source on the tool instances builder with `AddSource(name, configure)`. + +Operators then create instances of your new source exactly like the built-in ones, and expose them through the same MCP allow-list. ## Server Metadata diff --git a/src/CrestApps.Core.Docs/sidebars.js b/src/CrestApps.Core.Docs/sidebars.js index 87235172..c9e45181 100644 --- a/src/CrestApps.Core.Docs/sidebars.js +++ b/src/CrestApps.Core.Docs/sidebars.js @@ -71,7 +71,6 @@ const sidebars = { 'mcp/client', 'mcp/resource-types', 'mcp/server', - 'mcp/documentation-search', ], }, { diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/McpConstants.cs b/src/Primitives/CrestApps.Core.AI.Mcp/McpConstants.cs index 178d15ea..e187b9b4 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/McpConstants.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/McpConstants.cs @@ -12,11 +12,6 @@ public static class McpConstants /// public const string HttpClientName = "CrestApps.Mcp"; - /// - /// The name of the named used by the documentation search crawler. - /// - public const string DocumentationHttpClientName = "CrestApps.Documentation"; - /// /// Provides functionality for transport Types. /// diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs index 3683f4f8..2b60a27f 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs @@ -35,7 +35,7 @@ public static IMcpServerBuilder WithCrestAppsHandlers(this IMcpServerBuilder bui { var serverOptions = request.Services.GetRequiredService>().CurrentValue; var exposeAll = serverOptions.ExposeAllTools; - var allowList = BuildAllowList(serverOptions.Tools); + var allowList = exposeAll ? null : BuildAllowList(serverOptions.Tools); var toolDefinitions = request.Services.GetRequiredService>().Value; ILogger logger = null; var tools = new List(); @@ -120,7 +120,7 @@ public static IMcpServerBuilder WithCrestAppsHandlers(this IMcpServerBuilder bui { var serverOptions = request.Services.GetRequiredService>().CurrentValue; var exposeAll = serverOptions.ExposeAllTools; - var allowList = BuildAllowList(serverOptions.Tools); + var allowList = exposeAll ? null : BuildAllowList(serverOptions.Tools); var toolDefinitions = request.Services.GetRequiredService>().Value; var logger = request.Services.GetService>(); diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocSearchSite.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocSearchSite.cs similarity index 95% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocSearchSite.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocSearchSite.cs index 3efa606d..a5f0b1c8 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocSearchSite.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocSearchSite.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// Describes a documentation site that is searchable through the Algolia DocSearch query API (the diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationSource.cs similarity index 88% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSource.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationSource.cs index b1d4b1d4..5f67f6de 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationSource.cs @@ -1,10 +1,10 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; -using System.Text.Json.Serialization; +using System.Text.Json; using Microsoft.Extensions.Logging; -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// A built-in that searches a documentation site through the @@ -14,6 +14,8 @@ namespace CrestApps.Core.AI.Mcp.Documentation; /// public sealed class AlgoliaDocumentationSource : IDocumentationSource { + private static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); + private readonly AlgoliaDocSearchSite _site; private readonly DocumentationSearchOptions _options; private readonly IHttpClientFactory _httpClientFactory; @@ -100,14 +102,14 @@ public async Task> SearchAsync(Document private async Task> QueryAsync(string query, int maxResults, CancellationToken cancellationToken) { - var client = _httpClientFactory.CreateClient(McpConstants.DocumentationHttpClientName); + var client = _httpClientFactory.CreateClient(DocumentationToolConstants.HttpClientName); var requestUri = $"https://{_site.ApplicationId}-dsn.algolia.net/1/indexes/{Uri.EscapeDataString(_site.IndexName)}/query"; var parameters = $"query={Uri.EscapeDataString(query)}&hitsPerPage={maxResults}"; using var message = new HttpRequestMessage(HttpMethod.Post, requestUri) { - Content = JsonContent.Create(new AlgoliaQueryRequest { Params = parameters }), + Content = JsonContent.Create(new AlgoliaQueryRequest { Params = parameters }, options: _serializerOptions), }; message.Headers.TryAddWithoutValidation("X-Algolia-Application-Id", _site.ApplicationId); @@ -118,7 +120,7 @@ private async Task> QueryAsync(string query, int maxRe response.EnsureSuccessStatusCode(); - var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + var payload = await response.Content.ReadFromJsonAsync(_serializerOptions, cancellationToken); return payload?.Hits ?? []; } @@ -141,49 +143,37 @@ private static string ResolveTitle(AlgoliaHit hit) private sealed class AlgoliaQueryRequest { - [JsonPropertyName("params")] public string Params { get; set; } } private sealed class AlgoliaQueryResponse { - [JsonPropertyName("hits")] public IReadOnlyList Hits { get; set; } } private sealed class AlgoliaHit { - [JsonPropertyName("url")] public string Url { get; set; } - [JsonPropertyName("content")] public string Content { get; set; } - [JsonPropertyName("hierarchy")] public AlgoliaHierarchy Hierarchy { get; set; } } private sealed class AlgoliaHierarchy { - [JsonPropertyName("lvl0")] public string Lvl0 { get; set; } - [JsonPropertyName("lvl1")] public string Lvl1 { get; set; } - [JsonPropertyName("lvl2")] public string Lvl2 { get; set; } - [JsonPropertyName("lvl3")] public string Lvl3 { get; set; } - [JsonPropertyName("lvl4")] public string Lvl4 { get; set; } - [JsonPropertyName("lvl5")] public string Lvl5 { get; set; } - [JsonPropertyName("lvl6")] public string Lvl6 { get; set; } } } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSettings.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationToolSettings.cs similarity index 94% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSettings.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationToolSettings.cs index b29da1da..a3124734 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSettings.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationToolSettings.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// The user-provided settings for an Algolia DocSearch documentation search tool instance. The settings diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationToolSource.cs similarity index 97% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSource.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationToolSource.cs index 7a8237c6..a25d344d 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/AlgoliaDocumentationToolSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationToolSource.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// The built-in that lets users configure documentation search through diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/CachingDocumentationSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/CachingDocumentationSource.cs similarity index 98% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/CachingDocumentationSource.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/CachingDocumentationSource.cs index b60d04ce..40d4c35f 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/CachingDocumentationSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/CachingDocumentationSource.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// A base class for documentation sources that materialize a full diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceMaterializer.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DefaultDocumentationSourceMaterializer.cs similarity index 95% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceMaterializer.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DefaultDocumentationSourceMaterializer.cs index 3b1c5150..44b32821 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DefaultDocumentationSourceMaterializer.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DefaultDocumentationSourceMaterializer.cs @@ -1,6 +1,6 @@ using System.Collections.Concurrent; -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// The default . It keeps one cached diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationCorpus.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationCorpus.cs similarity index 98% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationCorpus.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationCorpus.cs index 11a1841e..f6695df1 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationCorpus.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationCorpus.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// An in-memory, keyword-searchable collection of documentation entries. Sources that materialize a diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchIndexSite.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchIndexSite.cs similarity index 95% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchIndexSite.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchIndexSite.cs index 6baacb6e..47dfdbe9 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchIndexSite.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchIndexSite.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// Describes a documentation site that publishes a prebuilt search index as JSON (for example a MkDocs diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchOptions.cs similarity index 94% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchOptions.cs index 1af96073..42d8ed00 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchOptions.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchOptions.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// Runtime limits that control how a built-in documentation search source crawls and ranks a single diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchRequest.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchRequest.cs similarity index 92% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchRequest.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchRequest.cs index 9311eb32..f3f3ec01 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchRequest.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchRequest.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// Represents a single documentation search query issued against an . diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchResult.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchResult.cs similarity index 94% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchResult.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchResult.cs index a43c6969..2ce901eb 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchResult.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchResult.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// Represents a single relevant document returned by an . diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchToolFunction.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchToolFunction.cs similarity index 99% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchToolFunction.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchToolFunction.cs index 9d3719ca..97a1f9da 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSearchToolFunction.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSearchToolFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// An produced by a documentation search tool instance source. Each function is diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSite.cs similarity index 96% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSite.cs index 859cb499..3a4f0cd7 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationSite.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationSite.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// Describes a public documentation site that the built-in documentation search crawler can scan. diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolConstants.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationToolConstants.cs similarity index 82% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolConstants.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationToolConstants.cs index 4529530d..ad27dd14 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolConstants.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationToolConstants.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// Well-known identifiers for the built-in documentation search tool instance sources. Each source is @@ -28,4 +28,9 @@ public static class DocumentationToolConstants /// The category applied to the documentation search sources so they are grouped as knowledge-base tools. /// public const string Category = "Knowledgebase"; + + /// + /// The name of the named used by the documentation search crawlers. + /// + public const string HttpClientName = "CrestApps.Documentation"; } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolInstanceServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationToolInstanceServiceCollectionExtensions.cs similarity index 96% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolInstanceServiceCollectionExtensions.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationToolInstanceServiceCollectionExtensions.cs index 62e40ddd..414015c5 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/DocumentationToolInstanceServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/DocumentationToolInstanceServiceCollectionExtensions.cs @@ -1,10 +1,9 @@ -using CrestApps.Core.AI; using CrestApps.Core.Builders; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Localization; -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// Convenience registration for the built-in documentation search tool instance sources. Each source is a @@ -123,7 +122,6 @@ private static void AddSharedServices(CrestAppsAIToolInstancesBuilder builder) { builder.Services.TryAddSingleton(TimeProvider.System); builder.Services.TryAddSingleton(); - builder.Services.AddHttpClient(McpConstants.DocumentationHttpClientName) - .AddStandardResilienceHandler(); + builder.Services.AddHttpClient(DocumentationToolConstants.HttpClientName); } } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/IDocumentationSource.cs similarity index 94% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSource.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/IDocumentationSource.cs index 0fc1051d..1fd0286d 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/IDocumentationSource.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// Represents a searchable documentation knowledge base. Implement this interface to expose a custom diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceMaterializer.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/IDocumentationSourceMaterializer.cs similarity index 94% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceMaterializer.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/IDocumentationSourceMaterializer.cs index 202e179b..ac79f419 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/IDocumentationSourceMaterializer.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/IDocumentationSourceMaterializer.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// Caches the runtime materialized for each configured documentation diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationSource.cs similarity index 91% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSource.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationSource.cs index a40e0112..61c7177a 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationSource.cs @@ -1,9 +1,9 @@ using System.Net.Http; using System.Net.Http.Json; -using System.Text.Json.Serialization; +using System.Text.Json; using Microsoft.Extensions.Logging; -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// A built-in that indexes a documentation site by downloading a @@ -13,6 +13,8 @@ namespace CrestApps.Core.AI.Mcp.Documentation; /// public sealed class SearchIndexDocumentationSource : CachingDocumentationSource { + private static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); + private readonly DocumentationSearchIndexSite _site; private readonly DocumentationSearchOptions _options; private readonly IHttpClientFactory _httpClientFactory; @@ -50,8 +52,8 @@ protected override async Task BuildCorpusAsync(Cancellation try { - var client = _httpClientFactory.CreateClient(McpConstants.DocumentationHttpClientName); - var index = await client.GetFromJsonAsync(indexUrl, cancellationToken); + var client = _httpClientFactory.CreateClient(DocumentationToolConstants.HttpClientName); + var index = await client.GetFromJsonAsync(indexUrl, _serializerOptions, cancellationToken); if (index?.Docs is null || index.Docs.Count == 0) { @@ -102,19 +104,15 @@ private string ResolveUrl(string location) private sealed class SearchIndexDocument { - [JsonPropertyName("docs")] public IReadOnlyList Docs { get; set; } } private sealed class SearchIndexEntry { - [JsonPropertyName("location")] public string Location { get; set; } - [JsonPropertyName("title")] public string Title { get; set; } - [JsonPropertyName("text")] public string Text { get; set; } } } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSettings.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationToolSettings.cs similarity index 95% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSettings.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationToolSettings.cs index ef213cc7..bcd99f4e 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSettings.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationToolSettings.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// The user-provided settings for a prebuilt search index documentation search tool instance. The diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationToolSource.cs similarity index 97% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSource.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationToolSource.cs index 46d4e5d1..d3309ae3 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SearchIndexDocumentationToolSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationToolSource.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// The built-in that lets users configure documentation search over a diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationSource.cs similarity index 97% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSource.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationSource.cs index 373fd778..f4070f70 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationSource.cs @@ -3,7 +3,7 @@ using System.Xml.Linq; using Microsoft.Extensions.Logging; -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// A built-in that indexes a public documentation site by reading @@ -53,7 +53,7 @@ protected override async Task BuildCorpusAsync(Cancellation private async Task> CrawlAsync(CancellationToken cancellationToken) { - var client = _httpClientFactory.CreateClient(McpConstants.DocumentationHttpClientName); + var client = _httpClientFactory.CreateClient(DocumentationToolConstants.HttpClientName); var urls = await GetSitemapUrlsAsync(client, cancellationToken); if (urls.Count == 0) diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSettings.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationToolSettings.cs similarity index 95% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSettings.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationToolSettings.cs index 5d6ff5c0..2cebc196 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSettings.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationToolSettings.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// The user-provided settings for a sitemap crawling documentation search tool instance. The settings diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationToolSource.cs similarity index 97% rename from src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSource.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationToolSource.cs index 7da47d32..d8807dcb 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Documentation/SitemapDocumentationToolSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationToolSource.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -namespace CrestApps.Core.AI.Mcp.Documentation; +namespace CrestApps.Core.AI.Tooling.Instances.Documentation; /// /// The built-in that lets users configure documentation search over a diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor index 07b07491..645ee549 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor @@ -5,7 +5,7 @@ @using CrestApps.Core.AI @using CrestApps.Core.AI.Tooling @using CrestApps.Core.AI.Tooling.Instances -@using CrestApps.Core.AI.Mcp.Documentation +@using CrestApps.Core.AI.Tooling.Instances.Documentation @using CrestApps.Core.Blazor.Web.ViewModels @using CrestApps.Core.Services @using Microsoft.Extensions.Options diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor index 56257364..eef44c7d 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor @@ -4,7 +4,7 @@ @using CrestApps.Core.AI @using CrestApps.Core.AI.Tooling @using CrestApps.Core.AI.Tooling.Instances -@using CrestApps.Core.AI.Mcp.Documentation +@using CrestApps.Core.AI.Tooling.Instances.Documentation @using CrestApps.Core.Blazor.Web.ViewModels @using CrestApps.Core.Services @using Microsoft.Extensions.Options diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Program.cs b/src/Startup/CrestApps.Core.Blazor.Web/Program.cs index 065cb094..0de0fd07 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Program.cs @@ -13,7 +13,7 @@ using CrestApps.Core.AI.Elasticsearch; using CrestApps.Core.AI.Markdown; using CrestApps.Core.AI.Mcp; -using CrestApps.Core.AI.Mcp.Documentation; +using CrestApps.Core.AI.Tooling.Instances.Documentation; using CrestApps.Core.AI.Mcp.Ftp; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Mcp.Sftp; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs index af34dedf..0b81f1e1 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs @@ -1,6 +1,6 @@ using System.Text.Json; using CrestApps.Core.AI; -using CrestApps.Core.AI.Mcp.Documentation; +using CrestApps.Core.AI.Tooling.Instances.Documentation; using CrestApps.Core.AI.Tooling; using CrestApps.Core.AI.Tooling.Instances; using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index a5b4a825..00fb55dd 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -13,7 +13,7 @@ using CrestApps.Core.AI.Elasticsearch; using CrestApps.Core.AI.Markdown; using CrestApps.Core.AI.Mcp; -using CrestApps.Core.AI.Mcp.Documentation; +using CrestApps.Core.AI.Tooling.Instances.Documentation; using CrestApps.Core.AI.Mcp.Ftp; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Mcp.Sftp; diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs b/tests/CrestApps.Core.Tests/Core/Tools/DocumentationSearchTests.cs similarity index 98% rename from tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs rename to tests/CrestApps.Core.Tests/Core/Tools/DocumentationSearchTests.cs index ed20aaa4..7b0cced5 100644 --- a/tests/CrestApps.Core.Tests/Core/Mcp/DocumentationSearchTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Tools/DocumentationSearchTests.cs @@ -1,9 +1,9 @@ -using CrestApps.Core.AI.Mcp.Documentation; +using CrestApps.Core.AI.Tooling.Instances.Documentation; using CrestApps.Core.AI.Tooling; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; -namespace CrestApps.Core.Tests.Core.Mcp; +namespace CrestApps.Core.Tests.Core.Tools; public sealed class DocumentationSearchTests { From 2f0984fbfb3c55da9dd19f92883d83cd3fb44c51 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 10 Aug 2026 16:46:57 +0300 Subject: [PATCH 11/13] Improve MCP server tool exposure settings UI - replace the free-form exposed tools textarea in both sample apps with grouped checkbox pickers for registered tools and configured tool instances - hide the exposed tools picker when the site setting exposes all tools and tool instances - remove "non-hidden" wording from the sample settings UI and MCP docs/changelog wording - populate the picker from AIToolDefinitionOptions and the AIToolInstance catalog, preserving selected values on validation errors - add select all/deselect all controls matching the AI Profile tool picker behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/changelog/1.1.0.md | 4 +- src/CrestApps.Core.Docs/docs/mcp/server.md | 6 +- .../Pages/Admin/Settings/Index.razor | 181 ++++++++++++++++-- .../ViewModels/SettingsViewModel.cs | 32 +++- .../Admin/Controllers/SettingsController.cs | 56 +++++- .../Admin/ViewModels/SettingsViewModel.cs | 34 +++- .../Areas/Admin/Views/Settings/Index.cshtml | 106 +++++++++- 7 files changed, 384 insertions(+), 35 deletions(-) diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index 233e05f5..1717f407 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -22,7 +22,7 @@ page will be updated as changes land after 1.0.0. ## Breaking Changes - **MCP servers no longer expose tools by default.** In 1.0.0 a server registered with - `WithCrestAppsHandlers()` listed and invoked every non-hidden registered tool. In 1.1.0 tool exposure is + `WithCrestAppsHandlers()` listed and invoked registered tools by default. In 1.1.0 tool exposure is opt-in through the new `McpServerOptions` site settings: nothing is listed or callable until you either add the tool or tool instance name to `McpServerOptions.Tools` or set `McpServerOptions.ExposeAllTools` to `true`. Hosts upgrading from 1.0.0 that relied on the previous "expose everything" behavior must set @@ -33,7 +33,7 @@ page will be updated as changes land after 1.0.0. - reworks tool exposure into an opt-in allow-list driven by the `McpServerOptions` site settings. Nothing is exposed by default: an MCP server lists and invokes only the tools and configured - [tool instances](../core/tool-instances.md) named in `McpServerOptions.Tools`, or every non-hidden tool + [tool instances](../core/tool-instances.md) named in `McpServerOptions.Tools`, or every tool and instance when `McpServerOptions.ExposeAllTools` is `true`. The allow-list is enforced by both the list and call handlers, so a tool that is not exposed can neither be discovered nor invoked. Because `McpServerOptions` is backed by site settings, operators choose which tools to expose from the admin diff --git a/src/CrestApps.Core.Docs/docs/mcp/server.md b/src/CrestApps.Core.Docs/docs/mcp/server.md index f409b25a..24947781 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/server.md +++ b/src/CrestApps.Core.Docs/docs/mcp/server.md @@ -307,7 +307,7 @@ services.Configure(options => // Expose specific tools and tool instances by name. options.Tools = ["crestapps-docs", "weather"]; - // Or expose every non-hidden tool and tool instance. + // Or expose every tool and tool instance. // When set to true, the Tools allow-list above is ignored. options.ExposeAllTools = true; }); @@ -316,9 +316,9 @@ services.Configure(options => | Property | Effect | |----------|--------| | `Tools` | An allow-list of tool and tool instance names to expose. Matching is case-insensitive. | -| `ExposeAllTools` | When `true`, every non-hidden tool and tool instance is exposed and the allow-list is ignored. | +| `ExposeAllTools` | When `true`, every tool and tool instance is exposed and the allow-list is ignored. | -Because `McpServerOptions` is backed by site settings, an operator can choose which tools to expose from the admin **Settings → MCP server** page without redeploying. The allow-list is enforced by **both** the list and call handlers, so a tool that is not exposed can neither be discovered nor invoked. `.Hidden()` tools are always excluded, even when `ExposeAllTools` is `true`. +Because `McpServerOptions` is backed by site settings, an operator can choose which tools to expose from the admin **Settings → MCP server** page without redeploying. The allow-list is enforced by **both** the list and call handlers, so a tool that is not exposed can neither be discovered nor invoked. Both code-registered tools (from `AddCoreAITool()`, see [Custom Tools](../core/tools.md)) and stored tool instances (from any registered [tool instance source](../core/tool-instances.md), such as the [documentation search sources](#exposing-a-documentation-knowledge-base)) participate in the same allow-list. diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Admin/Settings/Index.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Admin/Settings/Index.razor index 69904146..1ff87b23 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Admin/Settings/Index.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Admin/Settings/Index.razor @@ -12,13 +12,16 @@ @using CrestApps.Core.AI.Models @using CrestApps.Core.AI.Profiles @using CrestApps.Core.AI.Security +@using CrestApps.Core.AI.Tooling @using CrestApps.Core.Blazor.Web.Areas.AIChat.Services @using CrestApps.Core.Blazor.Web.Areas.ChatInteractions.Models @using CrestApps.Core.Blazor.Web.Models @using CrestApps.Core.Blazor.Web.Services @using CrestApps.Core.Blazor.Web.ViewModels @using CrestApps.Core.Infrastructure.Indexing +@using CrestApps.Core.Services @using Microsoft.AspNetCore.DataProtection +@using Microsoft.Extensions.Options @inject SiteSettingsStore SiteSettings @inject IAIDeploymentManager DeploymentManager @@ -28,6 +31,8 @@ @inject ClaudeClientService ClaudeClientService @inject SiteSettingsChangedNotifier SettingsChangedNotifier @inject ToastNotificationService ToastNotifications +@inject IOptions ToolOptionsAccessor +@inject ISourceCatalog ToolInstanceCatalog AI Settings - Blazor AI Integration Sample @@ -728,17 +733,75 @@ else

Choose which tools and configured tool instances are exposed to MCP clients. Nothing is - exposed by default; list the tools to allow, or expose everything. + exposed by default; select the tools to allow, or expose everything.

- -
-
- - -
One name per line (or comma-separated). Ignored when all tools are exposed.
+
+ @if (!_model.McpServerExposeAllTools) + { +
+
Exposed tools
+ @if (_model.McpServerAvailableTools.Count > 0 || _model.McpServerAvailableToolInstances.Count > 0) + { +
+

Select the tools and configured tool instances MCP clients can discover and invoke.

+
+ + +
+
+ } + @if (_model.McpServerAvailableTools.Count == 0) + { +
No AI tools are registered.
+ } + else + { + @foreach (var group in _model.McpServerAvailableTools.GroupBy(tool => tool.Category).OrderBy(group => group.Key)) + { +
@group.Key
+ @foreach (var tool in group) + { +
+ + +
+ } + } + } + +
Exposed tool instances
+ @if (_model.McpServerAvailableToolInstances.Count == 0) + { +
No tool instances are configured. Add them under AI Tool Instances first.
+ } + else + { + @foreach (var instance in _model.McpServerAvailableToolInstances) + { +
+ + +
+ } + } +
+ }
@@ -886,9 +949,7 @@ else McpServerApiKey = mcpServerSettings.ApiKey, McpServerRequireAccessPermission = mcpServerSettings.RequireAccessPermission, McpServerExposeAllTools = mcpServerSettings.ExposeAllTools, - McpServerExposedTools = mcpServerSettings.Tools is null - ? string.Empty - : string.Join(Environment.NewLine, mcpServerSettings.Tools), + McpServerSelectedToolNames = mcpServerSettings.Tools?.ToArray() ?? [], CopilotAuthenticationType = copilotSettings.AuthenticationType, CopilotClientId = copilotSettings.ClientId, @@ -960,9 +1021,42 @@ else .Select(p => new KeyValuePair(p.ItemId, p.DisplayText ?? p.Name)) .ToList(); + await PopulateMcpServerToolsAsync(); await PopulateClaudeModelsAsync(); } + private async Task PopulateMcpServerToolsAsync() + { + var selectedNames = new HashSet(_model.McpServerSelectedToolNames ?? [], StringComparer.OrdinalIgnoreCase); + + _model.McpServerAvailableTools = ToolOptionsAccessor.Value.Tools + .Where(tool => !tool.Value.Hidden) + .Select(tool => new McpServerToolSelectionItem + { + Name = tool.Key, + Title = tool.Value.Title ?? tool.Key, + Description = tool.Value.Description, + Category = tool.Value.Category ?? "Miscellaneous", + IsSelected = selectedNames.Contains(tool.Key) || selectedNames.Contains(tool.Value.Name), + }) + .OrderBy(tool => tool.Category, StringComparer.OrdinalIgnoreCase) + .ThenBy(tool => tool.Title, StringComparer.OrdinalIgnoreCase) + .ToList(); + + _model.McpServerAvailableToolInstances = (await ToolInstanceCatalog.GetAllAsync()) + .Where(instance => !string.IsNullOrWhiteSpace(instance.Name)) + .OrderBy(instance => instance.Name, StringComparer.OrdinalIgnoreCase) + .Select(instance => new McpServerToolInstanceSelectionItem + { + ItemId = instance.ItemId, + Name = instance.Name, + Description = instance.Description, + Source = instance.Source, + IsSelected = selectedNames.Contains(instance.Name), + }) + .ToList(); + } + private void OnCopilotAuthTypeChanged(ChangeEventArgs e) { if (Enum.TryParse(e.Value?.ToString(), out var value)) @@ -984,6 +1078,65 @@ else } } + private void ToggleMcpServerTool(string name, bool value) + { + var tool = _model.McpServerAvailableTools.FirstOrDefault(item => item.Name == name); + + if (tool is not null) + { + tool.IsSelected = value; + } + } + + private void ToggleMcpServerToolInstance(string itemId, bool value) + { + var instance = _model.McpServerAvailableToolInstances.FirstOrDefault(item => item.ItemId == itemId); + + if (instance is not null) + { + instance.IsSelected = value; + } + } + + private void SelectAllMcpServerTools() + { + foreach (var tool in _model.McpServerAvailableTools) + { + tool.IsSelected = true; + } + + foreach (var instance in _model.McpServerAvailableToolInstances) + { + instance.IsSelected = true; + } + } + + private void DeselectAllMcpServerTools() + { + foreach (var tool in _model.McpServerAvailableTools) + { + tool.IsSelected = false; + } + + foreach (var instance in _model.McpServerAvailableToolInstances) + { + instance.IsSelected = false; + } + } + + private string[] GetSelectedMcpServerToolNames() + { + return _model.McpServerAvailableTools + .Where(tool => tool.IsSelected) + .Select(tool => tool.Name) + .Concat(_model.McpServerAvailableToolInstances + .Where(instance => instance.IsSelected) + .Select(instance => instance.Name)) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + private void OnAnthropicAuthTypeChanged(ChangeEventArgs e) { if (Enum.TryParse(e.Value?.ToString(), out var value)) @@ -1030,6 +1183,7 @@ else private async Task HandleSaveAsync() { _errors.Clear(); + _model.McpServerSelectedToolNames = GetSelectedMcpServerToolNames(); if (_model.MaximumIterationsPerRequest < 1) { @@ -1202,12 +1356,9 @@ else ApiKey = _model.McpServerApiKey?.Trim(), RequireAccessPermission = _model.McpServerRequireAccessPermission, ExposeAllTools = _model.McpServerExposeAllTools, - Tools = string.IsNullOrWhiteSpace(_model.McpServerExposedTools) + Tools = _model.McpServerExposeAllTools ? [] - : _model.McpServerExposedTools - .Split(['\r', '\n', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(), + : _model.McpServerSelectedToolNames.ToList(), }); SiteSettings.Set(new MemoryMetadata diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/SettingsViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/SettingsViewModel.cs index 42fad678..5bb4b24c 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/SettingsViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/SettingsViewModel.cs @@ -53,7 +53,11 @@ public sealed class SettingsViewModel public bool McpServerExposeAllTools { get; set; } - public string McpServerExposedTools { get; set; } + public string[] McpServerSelectedToolNames { get; set; } = []; + + public List McpServerAvailableTools { get; set; } = []; + + public List McpServerAvailableToolInstances { get; set; } = []; // Default deployment settings. public string DefaultChatDeploymentName { get; set; } @@ -165,3 +169,29 @@ public sealed class SettingsViewModel public List> AnthropicAvailableModels { get; set; } = []; } + +public sealed class McpServerToolSelectionItem +{ + public string Name { get; set; } + + public string Title { get; set; } + + public string Description { get; set; } + + public string Category { get; set; } + + public bool IsSelected { get; set; } +} + +public sealed class McpServerToolInstanceSelectionItem +{ + public string ItemId { get; set; } + + public string Name { get; set; } + + public string Description { get; set; } + + public string Source { get; set; } + + public bool IsSelected { get; set; } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs index 4f9d2c9b..9ee14cae 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs @@ -12,17 +12,20 @@ using CrestApps.Core.AI.Profiles; using CrestApps.Core.AI.Security; using CrestApps.Core.AI.Speech; +using CrestApps.Core.AI.Tooling; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Mvc.Web.Areas.Admin.ViewModels; using CrestApps.Core.Mvc.Web.Areas.AIChat.Models; using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.Models; using CrestApps.Core.Mvc.Web.Models; +using CrestApps.Core.Services; using CrestApps.Core.Startup.Shared.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Options; namespace CrestApps.Core.Mvc.Web.Areas.Admin.Controllers; @@ -41,6 +44,8 @@ public sealed class SettingsController : Controller private readonly IDataProtectionProvider _dataProtectionProvider; private readonly ISpeechVoiceResolver _speechVoiceResolver; private readonly ClaudeClientService _anthropicClientService; + private readonly AIToolDefinitionOptions _toolOptions; + private readonly ISourceCatalog _toolInstanceCatalog; public SettingsController( SiteSettingsStore siteSettings, @@ -49,7 +54,9 @@ public SettingsController( ISearchIndexProfileStore indexProfileStore, IDataProtectionProvider dataProtectionProvider, ISpeechVoiceResolver speechVoiceResolver, - ClaudeClientService anthropicClientService) + ClaudeClientService anthropicClientService, + IOptions toolOptions, + ISourceCatalog toolInstanceCatalog) { _siteSettings = siteSettings; _deploymentManager = deploymentManager; @@ -58,6 +65,8 @@ public SettingsController( _dataProtectionProvider = dataProtectionProvider; _speechVoiceResolver = speechVoiceResolver; _anthropicClientService = anthropicClientService; + _toolOptions = toolOptions.Value; + _toolInstanceCatalog = toolInstanceCatalog; } public async Task Index() @@ -112,9 +121,7 @@ public async Task Index() McpServerApiKey = mcpServerSettings.ApiKey, McpServerRequireAccessPermission = mcpServerSettings.RequireAccessPermission, McpServerExposeAllTools = mcpServerSettings.ExposeAllTools, - McpServerExposedTools = mcpServerSettings.Tools is null - ? string.Empty - : string.Join(Environment.NewLine, mcpServerSettings.Tools), + McpServerSelectedToolNames = mcpServerSettings.Tools?.ToArray() ?? [], CopilotAuthenticationType = copilotSettings.AuthenticationType, CopilotClientId = copilotSettings.ClientId, CopilotHasSecret = !string.IsNullOrWhiteSpace(copilotSettings.ProtectedClientSecret), @@ -152,6 +159,7 @@ public async Task Index() await NormalizeDeploymentSelectorsAsync(model); await PopulateDeploymentDropdownsAsync(model); await PopulateAdminWidgetProfilesAsync(model); + await PopulateMcpServerToolsAsync(model); await PopulateClaudeModelsAsync(model); PopulateBlockingThresholds(model); @@ -270,6 +278,7 @@ public async Task Save(SettingsViewModel model) { await PopulateDeploymentDropdownsAsync(model); await PopulateAdminWidgetProfilesAsync(model); + await PopulateMcpServerToolsAsync(model); await PopulateClaudeModelsAsync(model); PopulateBlockingThresholds(model); @@ -343,10 +352,11 @@ public async Task Save(SettingsViewModel model) ApiKey = model.McpServerApiKey?.Trim(), RequireAccessPermission = model.McpServerRequireAccessPermission, ExposeAllTools = model.McpServerExposeAllTools, - Tools = string.IsNullOrWhiteSpace(model.McpServerExposedTools) + Tools = model.McpServerExposeAllTools ? [] - : model.McpServerExposedTools - .Split(['\r', '\n', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : (model.McpServerSelectedToolNames ?? []) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Select(name => name.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(), }); @@ -492,6 +502,38 @@ private async Task PopulateAdminWidgetProfilesAsync(SettingsViewModel model) profile.ItemId == model.AdminWidgetProfileId)); } + private async Task PopulateMcpServerToolsAsync(SettingsViewModel model) + { + var selectedNames = new HashSet(model.McpServerSelectedToolNames ?? [], StringComparer.OrdinalIgnoreCase); + + model.McpServerAvailableTools = _toolOptions.Tools + .Where(tool => !tool.Value.Hidden) + .Select(tool => new McpServerToolSelectionItem + { + Name = tool.Key, + Title = tool.Value.Title ?? tool.Key, + Description = tool.Value.Description, + Category = tool.Value.Category ?? "Miscellaneous", + IsSelected = selectedNames.Contains(tool.Key) || selectedNames.Contains(tool.Value.Name), + }) + .OrderBy(tool => tool.Category, StringComparer.OrdinalIgnoreCase) + .ThenBy(tool => tool.Title, StringComparer.OrdinalIgnoreCase) + .ToList(); + + model.McpServerAvailableToolInstances = (await _toolInstanceCatalog.GetAllAsync()) + .Where(instance => !string.IsNullOrWhiteSpace(instance.Name)) + .OrderBy(instance => instance.Name, StringComparer.OrdinalIgnoreCase) + .Select(instance => new McpServerToolInstanceSelectionItem + { + ItemId = instance.ItemId, + Name = instance.Name, + Description = instance.Description, + Source = instance.Source, + IsSelected = selectedNames.Contains(instance.Name), + }) + .ToList(); + } + private async Task PopulateClaudeModelsAsync(SettingsViewModel model) { var settings = _siteSettings.Get(); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs index 893ad7c3..85fd5b91 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs @@ -58,7 +58,13 @@ public sealed class SettingsViewModel public bool McpServerExposeAllTools { get; set; } - public string McpServerExposedTools { get; set; } + public string[] McpServerSelectedToolNames { get; set; } = []; + + [BindNever] + public List McpServerAvailableTools { get; set; } = []; + + [BindNever] + public List McpServerAvailableToolInstances { get; set; } = []; // Default deployment settings. public string DefaultChatDeploymentName { get; set; } @@ -188,3 +194,29 @@ public sealed class SettingsViewModel [BindNever] public IEnumerable BlockingThresholds { get; set; } = []; } + +public sealed class McpServerToolSelectionItem +{ + public string Name { get; set; } + + public string Title { get; set; } + + public string Description { get; set; } + + public string Category { get; set; } + + public bool IsSelected { get; set; } +} + +public sealed class McpServerToolInstanceSelectionItem +{ + public string ItemId { get; set; } + + public string Name { get; set; } + + public string Description { get; set; } + + public string Source { get; set; } + + public bool IsSelected { get; set; } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml index fbe23743..b022e21f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml @@ -626,16 +626,75 @@

Choose which tools and configured tool instances are exposed to MCP clients. Nothing is - exposed by default; list the tools to allow, or expose everything. + exposed by default; select the tools to allow, or expose everything.

- +
-
- - -
One name per line (or comma-separated). Ignored when all tools are exposed.
+
+
Exposed tools
+ @if (Model.McpServerAvailableTools.Count > 0 || Model.McpServerAvailableToolInstances.Count > 0) + { +
+

Select the tools and configured tool instances MCP clients can discover and invoke.

+
+ + +
+
+ } + @if (Model.McpServerAvailableTools.Count == 0) + { +
No AI tools are registered.
+ } + else + { + @foreach (var group in Model.McpServerAvailableTools.GroupBy(tool => tool.Category).OrderBy(group => group.Key)) + { +
@group.Key
+ @foreach (var tool in group) + { +
+ + +
+ } + } + } + +
Exposed tool instances
+ @if (Model.McpServerAvailableToolInstances.Count == 0) + { +
No tool instances are configured. Add them under AI Tool Instances first.
+ } + else + { + @foreach (var instance in Model.McpServerAvailableToolInstances) + { +
+ + +
+ } + }
@@ -875,6 +934,41 @@ loadVoiceOptions(); }); + + diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml index 44024520..851ee576 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml @@ -5,6 +5,7 @@ @ViewData["Title"] - MVC AI Integration Sample + @@ -183,6 +184,7 @@ + @await RenderSectionAsync("Scripts", required: false) @await Html.PartialAsync("_ToastNotifications") From 2741c7c6a606254f2f29c05658e620635de17787 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 10 Aug 2026 23:23:14 +0300 Subject: [PATCH 13/13] Use GetOrCreate for tool instance settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Instances/Documentation/AlgoliaDocumentationToolSource.cs | 4 +--- .../Documentation/SearchIndexDocumentationToolSource.cs | 4 +--- .../Instances/Documentation/SitemapDocumentationToolSource.cs | 4 +--- .../Tooling/Instances/HttpApiRequestToolInstanceSource.cs | 4 +--- .../Areas/Tooling/Controllers/AIToolInstanceController.cs | 2 +- 5 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationToolSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationToolSource.cs index a25d344d..eabd8263 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationToolSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/AlgoliaDocumentationToolSource.cs @@ -23,9 +23,7 @@ public AITool CreateTool(AIToolInstance instance) { ArgumentNullException.ThrowIfNull(instance); - var settings = instance.TryGet(out var stored) - ? stored - : new AlgoliaDocumentationToolSettings(); + var settings = instance.GetOrCreate(); var functionName = instance.GetFunctionName(); var description = string.IsNullOrWhiteSpace(instance.Description) diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationToolSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationToolSource.cs index d3309ae3..1e116bb2 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationToolSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SearchIndexDocumentationToolSource.cs @@ -24,9 +24,7 @@ public AITool CreateTool(AIToolInstance instance) { ArgumentNullException.ThrowIfNull(instance); - var settings = instance.TryGet(out var stored) - ? stored - : new SearchIndexDocumentationToolSettings(); + var settings = instance.GetOrCreate(); var functionName = instance.GetFunctionName(); var description = string.IsNullOrWhiteSpace(instance.Description) diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationToolSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationToolSource.cs index d8807dcb..8094159a 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationToolSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/Documentation/SitemapDocumentationToolSource.cs @@ -23,9 +23,7 @@ public AITool CreateTool(AIToolInstance instance) { ArgumentNullException.ThrowIfNull(instance); - var settings = instance.TryGet(out var stored) - ? stored - : new SitemapDocumentationToolSettings(); + var settings = instance.GetOrCreate(); var functionName = instance.GetFunctionName(); var description = string.IsNullOrWhiteSpace(instance.Description) diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs index 33c53db7..d6aac268 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs @@ -21,9 +21,7 @@ public AITool CreateTool(AIToolInstance instance) { ArgumentNullException.ThrowIfNull(instance); - var settings = instance.TryGet(out var stored) - ? stored - : new HttpApiRequestToolSettings(); + var settings = instance.GetOrCreate(); var functionName = instance.GetFunctionName(); var description = string.IsNullOrWhiteSpace(instance.Description) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs index 0b81f1e1..1ffe50a4 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs @@ -447,7 +447,7 @@ private void Apply(AIToolInstanceViewModel model, AIToolInstance instance, bool } var protector = _dataProtectionProvider.CreateProtector(HttpApiRequestToolConstants.DataProtectionPurpose); - var existing = instance.TryGet(out var stored) ? stored : new HttpApiRequestToolSettings(); + var existing = instance.GetOrCreate(); var settings = new HttpApiRequestToolSettings {