diff --git a/build/Targets.fs b/build/Targets.fs index f01e86ae6..cf0a64a1f 100644 --- a/build/Targets.fs +++ b/build/Targets.fs @@ -110,7 +110,7 @@ let private publishContainers _ = let createImage projectPath containerName = let ci = Environment.environVarOrNone "GITHUB_ACTIONS" let pr = prNumber() - let baseImageTag = "9.0-noble-chiseled-aot" + let baseImageTag = "10.0-noble-chiseled" let labels = imageTags() let args = ["publish"; projectPath] diff --git a/src/Elastic.Documentation.Configuration/IEnvironmentVariables.cs b/src/Elastic.Documentation.Configuration/IEnvironmentVariables.cs index a8173f374..1781abe62 100644 --- a/src/Elastic.Documentation.Configuration/IEnvironmentVariables.cs +++ b/src/Elastic.Documentation.Configuration/IEnvironmentVariables.cs @@ -64,4 +64,11 @@ public interface IEnvironmentVariables /// Reads MCP_ALLOWED_EMAIL_DOMAINS. /// string McpAllowedEmailDomains => GetEnvironmentVariable("MCP_ALLOWED_EMAIL_DOMAINS") ?? "elastic.co"; + + /// + /// MCP server profile name (e.g. "public", "internal"). Defaults to "public". + /// Reads MCP_SERVER_PROFILE. + /// + string McpServerProfile => GetEnvironmentVariable("MCP_SERVER_PROFILE") ?? "public"; + } diff --git a/src/Elastic.Documentation.Configuration/SystemEnvironmentVariables.cs b/src/Elastic.Documentation.Configuration/SystemEnvironmentVariables.cs index b12ce984b..b8e2d010d 100644 --- a/src/Elastic.Documentation.Configuration/SystemEnvironmentVariables.cs +++ b/src/Elastic.Documentation.Configuration/SystemEnvironmentVariables.cs @@ -44,4 +44,8 @@ public class SystemEnvironmentVariables : IEnvironmentVariables /// public string McpAllowedEmailDomains => GetEnvironmentVariable("MCP_ALLOWED_EMAIL_DOMAINS") ?? "elastic.co"; + + /// + public string McpServerProfile => GetEnvironmentVariable("MCP_SERVER_PROFILE") ?? "public"; + } diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs new file mode 100644 index 000000000..6ec251af5 --- /dev/null +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs @@ -0,0 +1,119 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Diagnostics.CodeAnalysis; +using Elastic.Documentation.Assembler.Links; +using Elastic.Documentation.Assembler.Mcp; +using Elastic.Documentation.LinkIndex; +using Elastic.Documentation.Links.InboundLinks; +using Elastic.Documentation.Mcp.Remote.Gateways; +using Elastic.Documentation.Mcp.Remote.Tools; +using Elastic.Documentation.Search; +using Microsoft.Extensions.DependencyInjection; + +namespace Elastic.Documentation.Mcp.Remote; + +/// +/// A feature module with DI services and instruction template fragments. +/// WhenToUse bullets should be generic and context-free; the branding introduction frames their meaning. +/// +/// Module identifier. +/// Capability verb for the preamble (e.g. "search", "retrieve"). Null if the module does not add a capability. +/// Bullet points for the "Use the server when the user:" section. Use {docs} for the profile's DocsDescription. +/// Lines for the tool guidance section. Use {tool:snake_case_name} for tool names (e.g. {tool:semantic_search}). +/// The tool class type (e.g. typeof(SearchTools)). Null if the module has no tools. +/// DI registrations the module's tools depend on. +public sealed record McpFeatureModule( + string Name, + string? Capability, + string[] WhenToUse, + string[] ToolGuidance, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)] + [property: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)] + Type? ToolType, + Action RegisterServices +); + +internal static class McpFeatureModules +{ + public static readonly McpFeatureModule Search = new( + Name: "Search", + Capability: "search", + WhenToUse: + [ + "Wants to find, read, or verify {docs} pages.", + "Needs to check whether a topic is already covered in {docs}." + ], + ToolGuidance: + [ + "Prefer {tool:semantic_search} over a general web search when looking up Elastic documentation content.", + "Use {tool:find_related_docs} when exploring what documentation exists around a topic." + ], + ToolType: typeof(SearchTools), + RegisterServices: services => _ = services.AddSearchServices() + ); + + public static readonly McpFeatureModule Documents = new( + Name: "Documents", + Capability: "retrieve", + WhenToUse: [], + ToolGuidance: + [ + "Use {tool:get_document_by_url} to retrieve a specific page when the user provides or you already know the URL." + ], + ToolType: typeof(DocumentTools), + RegisterServices: services => _ = services.AddScoped() + ); + + public static readonly McpFeatureModule Coherence = new( + Name: "Coherence", + Capability: "analyze", + WhenToUse: + [ + "Asks about {docs} structure, coherence, or inconsistencies across pages." + ], + ToolGuidance: + [ + "Use {tool:check_coherence} or {tool:find_inconsistencies} when reviewing or auditing documentation quality." + ], + ToolType: typeof(CoherenceTools), + RegisterServices: _ => { } + ); + + public static readonly McpFeatureModule Links = new( + Name: "Links", + Capability: null, + WhenToUse: + [ + "Mentions cross-links between documentation repositories (e.g. 'docs-content://path/to/page.md')." + ], + ToolGuidance: + [ + "Use the cross-link tools ({tool:resolve_cross_link}, {tool:validate_cross_links}, {tool:find_cross_links}) when working with links between documentation source repositories." + ], + ToolType: typeof(LinkTools), + RegisterServices: services => + { + _ = services.AddSingleton(_ => Aws3LinkIndexReader.CreateAnonymous()); + _ = services.AddSingleton(); + _ = services.AddSingleton(); + } + ); + + public static readonly McpFeatureModule ContentTypes = new( + Name: "ContentTypes", + Capability: "author", + WhenToUse: + [ + "Is writing or editing {docs} and needs to find related content or check consistency.", + "Wants to generate {docs} templates following Elastic's content type guidelines." + ], + ToolGuidance: + [ + "Use {tool:list_content_types}, {tool:get_content_type_guidelines}, and {tool:generate_template} when creating new pages." + ], + ToolType: typeof(ContentTypeTools), + RegisterServices: services => _ = services.AddSingleton() + ); +} diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs new file mode 100644 index 000000000..2c61dfef9 --- /dev/null +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs @@ -0,0 +1,145 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text; +using Microsoft.Extensions.DependencyInjection; + +namespace Elastic.Documentation.Mcp.Remote; + +/// +/// MCP server profile that selects which feature modules are enabled and how the server +/// introduces itself. Module WhenToUse bullets use {docs} placeholders that are replaced +/// with the profile's DocsDescription at composition time. +/// +/// Profile identifier (e.g. "public", "internal"). +/// Prefix for all tool names (e.g. "public_docs_", "internal_docs_"). +/// Short noun phrase describing this profile's docs (e.g. "Elastic product documentation"). Used to replace {docs} in trigger templates. +/// Introduction template with a {capabilities} placeholder replaced at composition time. +/// Profile-specific trigger bullets appended after module triggers. +/// Enabled feature modules. +public sealed record McpServerProfile( + string Name, + string ToolNamePrefix, + string DocsDescription, + string Introduction, + string[] ExtraTriggers, + McpFeatureModule[] Modules) +{ + public static McpServerProfile Public { get; } = new( + "public", + "public_docs_", + "Elastic documentation", + "Use this server to {capabilities} Elastic product documentation published at elastic.co/docs.", + ["References Elastic product names such as Elasticsearch, Kibana, Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud."], + [McpFeatureModules.Search, McpFeatureModules.Documents, McpFeatureModules.Coherence, McpFeatureModules.Links, McpFeatureModules.ContentTypes] + ); + + public static McpServerProfile Internal { get; } = new( + "internal", + "internal_docs_", + "Elastic internal documentation", + "Use this server to {capabilities} Elastic internal documentation: team processes, run books, architecture, and other internal knowledge.", + ["Asks about internal team processes, run books, architecture decisions, or operational knowledge."], + [McpFeatureModules.Search, McpFeatureModules.Documents] + ); + + /// + /// Resolves a profile by name. Throws if the name is unknown. + /// + public static McpServerProfile Resolve(string? name) + { + var key = string.IsNullOrWhiteSpace(name) ? "public" : name.Trim(); + return key.ToLowerInvariant() switch + { + "public" => Public, + "internal" => Internal, + _ => throw new ArgumentException($"Unknown MCP server profile: '{name}'. Valid values: public, internal.", nameof(name)) + }; + } + + /// + /// Registers all DI services from enabled modules, including tool types for resolution at invocation time. + /// + public void RegisterAllServices(IServiceCollection services) + { + foreach (var module in Modules) + { + module.RegisterServices(services); + if (module.ToolType is not null) + _ = services.AddScoped(module.ToolType); + } + } + + /// + /// Composes server instructions from the profile introduction and enabled module fragments. + /// + public string ComposeServerInstructions() + { + var capabilities = DeriveCapabilities(); + var introduction = Introduction.Replace("{capabilities}", capabilities, StringComparison.Ordinal); + + var whenToUse = Modules + .SelectMany(m => m.WhenToUse) + .Distinct() + .Select(line => line.Replace("{docs}", DocsDescription, StringComparison.Ordinal)) + .Concat(ExtraTriggers) + .ToList(); + var toolGuidance = Modules + .SelectMany(m => m.ToolGuidance) + .Select(line => ReplaceToolPlaceholders(line, ToolNamePrefix)) + .ToList(); + + var whenToUseBlock = whenToUse.Count > 0 + ? "\n" + string.Join("\n", whenToUse.Select(b => $"- {b}")) + : ""; + var toolGuidanceBlock = toolGuidance.Count > 0 + ? "\n\n" + string.Join("\n", toolGuidance.Select(l => $"- {l}")) + "\n" + : ""; + + return $""" + {introduction} + + + Use the server when the user:{whenToUseBlock} + + {toolGuidanceBlock} + """; + } + + private static string ReplaceToolPlaceholders(string line, string prefix) + { + var sb = new StringBuilder(line.Length); + var pos = 0; + int start; + while ((start = line.IndexOf("{tool:", pos, StringComparison.Ordinal)) >= 0) + { + var end = line.IndexOf('}', start); + if (end < 0) + break; + _ = sb.Append(line, pos, start - pos); + _ = sb.Append(prefix); + _ = sb.Append(line, start + 6, end - start - 6); + pos = end + 1; + } + _ = sb.Append(line, pos, line.Length - pos); + return sb.ToString(); + } + + private string DeriveCapabilities() + { + var verbs = Modules + .Select(m => m.Capability) + .Where(c => !string.IsNullOrEmpty(c)) + .Distinct() + .ToList(); + + return verbs.Count switch + { + 0 => "search and retrieve", + 1 => verbs[0]!, + 2 => $"{verbs[0]} and {verbs[1]}", + _ => string.Join(", ", verbs.Take(verbs.Count - 1)) + ", and " + verbs[^1] + }; + } +} diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs new file mode 100644 index 000000000..f5010a8d6 --- /dev/null +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs @@ -0,0 +1,67 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Reflection; +using System.Runtime.CompilerServices; +using ModelContextProtocol.Server; + +namespace Elastic.Documentation.Mcp.Remote; + +/// +/// Creates MCP tools with profile-based name prefixes. +/// +public static class McpToolRegistration +{ + /// + /// Creates prefixed tools for all enabled modules in the profile. + /// Uses createTargetFunc so tool instances are resolved from the request's service provider at invocation time. + /// + public static IEnumerable CreatePrefixedTools(McpServerProfile profile) + { + var prefix = profile.ToolNamePrefix; + var tools = new List(); + + foreach (var module in profile.Modules) + { + if (module.ToolType is null) + continue; + + var methods = module.ToolType + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.GetCustomAttribute() != null); + + foreach (var method in methods) + { + var snakeName = ToSnakeCase(method.Name); + var prefixedName = prefix + snakeName; + + var options = new McpServerToolCreateOptions { Name = prefixedName }; + + var tool = McpServerTool.Create( + method, + ctx => (ctx.Services ?? throw new InvalidOperationException("RequestContext.Services is null")).GetRequiredService(module.ToolType), + options); + + tools.Add(tool); + } + } + + return tools; + } + + /// + /// Converts PascalCase to snake_case (e.g. SemanticSearch → semantic_search). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string ToSnakeCase(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + return string.Concat(value.Select((c, i) => + i > 0 && char.IsUpper(c) + ? $"_{char.ToLowerInvariant(c)}" + : $"{char.ToLowerInvariant(c)}")); + } +} diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs index 8cbb8a306..fd0ca21ec 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs @@ -3,14 +3,8 @@ // See the LICENSE file in the project root for more information using Elastic.Documentation.Api.Infrastructure.OpenTelemetry; -using Elastic.Documentation.Assembler.Links; -using Elastic.Documentation.Assembler.Mcp; using Elastic.Documentation.Configuration; -using Elastic.Documentation.LinkIndex; -using Elastic.Documentation.Links.InboundLinks; using Elastic.Documentation.Mcp.Remote; -using Elastic.Documentation.Mcp.Remote.Gateways; -using Elastic.Documentation.Mcp.Remote.Tools; using Elastic.Documentation.Search; using Elastic.Documentation.ServiceDefaults; using Microsoft.AspNetCore.Builder; @@ -37,15 +31,10 @@ var environment = Environment.GetEnvironmentVariable("ENVIRONMENT"); Console.WriteLine($"Docs Environment: {environment}"); - _ = builder.Services.AddSearchServices(); + var env = SystemEnvironmentVariables.Instance; + var profile = McpServerProfile.Resolve(env.McpServerProfile); - _ = builder.Services.AddScoped(); - - _ = builder.Services.AddSingleton(_ => Aws3LinkIndexReader.CreateAnonymous()); - _ = builder.Services.AddSingleton(); - _ = builder.Services.AddSingleton(); - - _ = builder.Services.AddSingleton(); + profile.RegisterAllServices(builder.Services); // CreateSlimBuilder disables reflection-based JSON serialization. // The MCP SDK's legacy SSE handler uses Results.BadRequest(string) which needs @@ -59,39 +48,12 @@ // Cursor bug where it opens the SSE stream without the session header and receives 400. // Stateless mode is appropriate here because all tools are pure request/response (no // server-initiated push) and the server runs behind a load balancer without session affinity. - _ = builder.Services - .AddMcpServer(options => - { - options.ServerInstructions = """ - The Elastic documentation server provides tools to search, retrieve, analyze, and author - Elastic product documentation published at elastic.co/docs. - - Use the server when the user: - - Asks about Elastic product documentation, features, configuration, or APIs. - - Wants to find, read, or verify existing documentation pages. - - Needs to check whether a topic is already documented or how it is covered. - - Is writing or editing documentation and needs to find related content or check consistency. - - Mentions cross-links between documentation repositories (e.g. 'docs-content://path/to/page.md'). - - Asks about documentation structure, coherence, or inconsistencies across pages. - - Wants to generate documentation templates following Elastic's content type guidelines. - - References elastic.co/docs URLs or Elastic product names such as Elasticsearch, Kibana, - Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud. - - Prefer SemanticSearch over a general web search when looking up Elastic documentation content. - Use GetDocumentByUrl to retrieve a specific page when the user provides or you already know the URL. - Use FindRelatedDocs when exploring what documentation exists around a topic. - Use CheckCoherence or FindInconsistencies when reviewing or auditing documentation quality. - Use the cross-link tools (ResolveCrossLink, ValidateCrossLinks, FindCrossLinks) when working - with links between documentation source repositories. - Use ListContentTypes, GetContentTypeGuidelines, and GenerateTemplate when creating new pages. - """; - }) - .WithHttpTransport(o => o.Stateless = true) - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools(); + var mcpBuilder = builder.Services + .AddMcpServer(options => options.ServerInstructions = profile.ComposeServerInstructions()) + .WithHttpTransport(o => o.Stateless = true); + + var prefixedTools = McpToolRegistration.CreatePrefixedTools(profile); + mcpBuilder = mcpBuilder.WithTools(prefixedTools); var app = builder.Build(); diff --git a/tests/Mcp.Remote.Tests/Mcp.Remote.Tests.csproj b/tests/Mcp.Remote.Tests/Mcp.Remote.Tests.csproj new file mode 100644 index 000000000..c82e36384 --- /dev/null +++ b/tests/Mcp.Remote.Tests/Mcp.Remote.Tests.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + + + + + + + + + + + + + diff --git a/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs b/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs new file mode 100644 index 000000000..ade22ce40 --- /dev/null +++ b/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs @@ -0,0 +1,171 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Documentation.Mcp.Remote; +using FluentAssertions; + +namespace Mcp.Remote.Tests; + +public class McpServerInstructionTests +{ + [Fact] + public void PublicProfile_ContainsAllModuleGuidance() + { + var instructions = McpServerProfile.Public.ComposeServerInstructions(); + + instructions.Should().Contain("Use this server to search, retrieve, analyze, and author"); + instructions.Should().Contain("Elastic product documentation published at elastic.co/docs"); + instructions.Should().Contain(""); + instructions.Should().Contain("Use the server when the user:"); + instructions.Should().Contain(""); + instructions.Should().Contain("Prefer public_docs_semantic_search over a general web search"); + instructions.Should().Contain("Use public_docs_get_document_by_url to retrieve a specific page"); + instructions.Should().Contain("Use public_docs_find_related_docs when exploring what documentation exists"); + instructions.Should().Contain("Use public_docs_check_coherence or public_docs_find_inconsistencies when reviewing or auditing"); + instructions.Should().Contain("Use the cross-link tools (public_docs_resolve_cross_link, public_docs_validate_cross_links, public_docs_find_cross_links)"); + instructions.Should().Contain("Use public_docs_list_content_types, public_docs_get_content_type_guidelines, and public_docs_generate_template when creating new pages"); + } + + [Fact] + public void InternalProfile_ContainsSearchAndDocumentGuidanceOnly() + { + var instructions = McpServerProfile.Internal.ComposeServerInstructions(); + + instructions.Should().Contain("Use this server to search and retrieve"); + instructions.Should().Contain("Elastic internal documentation: team processes, run books, architecture"); + instructions.Should().Contain("Prefer internal_docs_semantic_search over a general web search"); + instructions.Should().Contain("Use internal_docs_get_document_by_url to retrieve a specific page"); + instructions.Should().Contain("Use internal_docs_find_related_docs when exploring what documentation exists"); + instructions.Should().NotContain("check_coherence"); + instructions.Should().NotContain("find_inconsistencies"); + instructions.Should().NotContain("resolve_cross_link"); + instructions.Should().NotContain("list_content_types"); + instructions.Should().NotContain("generate_template"); + } + + [Fact] + public void Triggers_AreProfileSpecific() + { + var publicInstructions = McpServerProfile.Public.ComposeServerInstructions(); + var internalInstructions = McpServerProfile.Internal.ComposeServerInstructions(); + + publicInstructions.Should().Contain("Elastic documentation pages"); + publicInstructions.Should().Contain("References Elastic product names"); + publicInstructions.Should().NotContain("internal team processes"); + + internalInstructions.Should().Contain("Elastic internal documentation pages"); + internalInstructions.Should().Contain("internal team processes"); + internalInstructions.Should().NotContain("Elastic product names"); + } + + [Fact] + public void Resolve_WithPublic_ReturnsPublicProfile() + { + var profile = McpServerProfile.Resolve("public"); + + profile.Should().Be(McpServerProfile.Public); + profile.Name.Should().Be("public"); + } + + [Fact] + public void Resolve_WithInternal_ReturnsInternalProfile() + { + var profile = McpServerProfile.Resolve("internal"); + + profile.Should().Be(McpServerProfile.Internal); + profile.Name.Should().Be("internal"); + } + + [Fact] + public void Resolve_WithNullOrWhitespace_ReturnsPublicProfile() + { + McpServerProfile.Resolve(null).Should().Be(McpServerProfile.Public); + McpServerProfile.Resolve("").Should().Be(McpServerProfile.Public); + McpServerProfile.Resolve(" ").Should().Be(McpServerProfile.Public); + } + + [Fact] + public void Resolve_WithUnknownProfile_Throws() + { + var act = () => McpServerProfile.Resolve("unknown"); + + act.Should().Throw() + .WithMessage("*Unknown MCP server profile*") + .WithParameterName("name"); + } + + [Fact] + public void PublicProfile_ComposesExactInstructions() + { + var instructions = McpServerProfile.Public.ComposeServerInstructions(); + + var expected = """ + Use this server to search, retrieve, analyze, and author Elastic product documentation published at elastic.co/docs. + + + Use the server when the user: + - Wants to find, read, or verify Elastic documentation pages. + - Needs to check whether a topic is already covered in Elastic documentation. + - Asks about Elastic documentation structure, coherence, or inconsistencies across pages. + - Mentions cross-links between documentation repositories (e.g. 'docs-content://path/to/page.md'). + - Is writing or editing Elastic documentation and needs to find related content or check consistency. + - Wants to generate Elastic documentation templates following Elastic's content type guidelines. + - References Elastic product names such as Elasticsearch, Kibana, Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud. + + + + - Prefer public_docs_semantic_search over a general web search when looking up Elastic documentation content. + - Use public_docs_find_related_docs when exploring what documentation exists around a topic. + - Use public_docs_get_document_by_url to retrieve a specific page when the user provides or you already know the URL. + - Use public_docs_check_coherence or public_docs_find_inconsistencies when reviewing or auditing documentation quality. + - Use the cross-link tools (public_docs_resolve_cross_link, public_docs_validate_cross_links, public_docs_find_cross_links) when working with links between documentation source repositories. + - Use public_docs_list_content_types, public_docs_get_content_type_guidelines, and public_docs_generate_template when creating new pages. + + """; + + instructions.Should().Be(expected); + } + + [Fact] + public void InternalProfile_ComposesExactInstructions() + { + var instructions = McpServerProfile.Internal.ComposeServerInstructions(); + + var expected = """ + Use this server to search and retrieve Elastic internal documentation: team processes, run books, architecture, and other internal knowledge. + + + Use the server when the user: + - Wants to find, read, or verify Elastic internal documentation pages. + - Needs to check whether a topic is already covered in Elastic internal documentation. + - Asks about internal team processes, run books, architecture decisions, or operational knowledge. + + + + - Prefer internal_docs_semantic_search over a general web search when looking up Elastic documentation content. + - Use internal_docs_find_related_docs when exploring what documentation exists around a topic. + - Use internal_docs_get_document_by_url to retrieve a specific page when the user provides or you already know the URL. + + """; + + instructions.Should().Be(expected); + } + + private static List ExtractBullets(string instructions) => + instructions + .Split('\n') + .Where(l => l.TrimStart().StartsWith("- ", StringComparison.Ordinal)) + .Select(l => l.TrimStart()[2..]) + .ToList(); + + private static List ExtractTriggersBullets(string instructions) + { + var start = instructions.IndexOf("", StringComparison.Ordinal); + var end = instructions.IndexOf("", StringComparison.Ordinal); + if (start < 0 || end < 0) + return []; + var section = instructions[start..end]; + return ExtractBullets(section); + } +}