From 37252a90d860c2a40457df13296cd4462bd0dcac Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 27 Feb 2026 12:43:47 +0100 Subject: [PATCH 1/6] Add configurable MCP server profiles (public, internal) - Introduce McpServerProfile and McpFeatureModule for compositional tool and instruction assembly - Public profile: all tools (search, documents, coherence, links, content types) - Internal profile: search and documents only, with intro for team processes, run books, architecture - Add MCP_SERVER_PROFILE env var, default "public" - Add XML tags (triggers, tool_guidance) for LLM-readable instructions - Add unit tests for instruction composition Made-with: Cursor --- .../IEnvironmentVariables.cs | 7 + .../SystemEnvironmentVariables.cs | 4 + .../McpFeatureModule.cs | 110 ++++++++++++ .../McpServerProfile.cs | 103 ++++++++++++ .../Program.cs | 65 +++---- .../Mcp.Remote.Tests/Mcp.Remote.Tests.csproj | 17 ++ .../McpServerInstructionTests.cs | 159 ++++++++++++++++++ 7 files changed, 420 insertions(+), 45 deletions(-) create mode 100644 src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs create mode 100644 src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs create mode 100644 tests/Mcp.Remote.Tests/Mcp.Remote.Tests.csproj create mode 100644 tests/Mcp.Remote.Tests/McpServerInstructionTests.cs 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..f74c2b8b3 --- /dev/null +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs @@ -0,0 +1,110 @@ +// 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.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. +/// Generic bullet points for the "Use the server when the user:" section. +/// Lines for the tool guidance section. +/// DI registrations the module's tools depend on. +public sealed record McpFeatureModule( + string Name, + string? Capability, + string[] WhenToUse, + string[] ToolGuidance, + Action RegisterServices +); + +internal static class McpFeatureModules +{ + public static readonly McpFeatureModule Search = new( + Name: "Search", + Capability: "search", + WhenToUse: + [ + "Wants to find, read, or verify existing documentation pages.", + "Needs to check whether a topic is already documented or how it is covered.", + "References documentation URLs or Elastic product names such as Elasticsearch, Kibana, Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud." + ], + ToolGuidance: + [ + "Prefer SemanticSearch over a general web search when looking up Elastic documentation content.", + "Use FindRelatedDocs when exploring what documentation exists around a topic." + ], + RegisterServices: services => _ = services.AddSearchServices() + ); + + public static readonly McpFeatureModule Documents = new( + Name: "Documents", + Capability: "retrieve", + WhenToUse: [], + ToolGuidance: + [ + "Use GetDocumentByUrl to retrieve a specific page when the user provides or you already know the URL." + ], + RegisterServices: services => _ = services.AddScoped() + ); + + public static readonly McpFeatureModule Coherence = new( + Name: "Coherence", + Capability: "analyze", + WhenToUse: + [ + "Asks about documentation structure, coherence, or inconsistencies across pages." + ], + ToolGuidance: + [ + "Use CheckCoherence or FindInconsistencies when reviewing or auditing documentation quality." + ], + 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 (ResolveCrossLink, ValidateCrossLinks, FindCrossLinks) when working with links between documentation source repositories." + ], + 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 documentation and needs to find related content or check consistency.", + "Wants to generate documentation templates following Elastic's content type guidelines." + ], + ToolGuidance: + [ + "Use ListContentTypes, GetContentTypeGuidelines, and GenerateTemplate when creating new pages." + ], + 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..d31c707a7 --- /dev/null +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs @@ -0,0 +1,103 @@ +// 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 Microsoft.Extensions.DependencyInjection; + +namespace Elastic.Documentation.Mcp.Remote; + +/// +/// MCP server profile that selects which feature modules are enabled and how the server +/// introduces itself. The introduction frames the server's purpose; module WhenToUse +/// bullets provide generic triggers that gain meaning from this context. +/// +/// Profile identifier (e.g. "public", "internal"). +/// Introduction template with a {capabilities} placeholder replaced at composition time. +/// Enabled feature modules. +public sealed record McpServerProfile(string Name, string Introduction, McpFeatureModule[] Modules) +{ + public static McpServerProfile Public { get; } = new( + "public", + "Use this server to {capabilities} Elastic product documentation published at elastic.co/docs.", + [McpFeatureModules.Search, McpFeatureModules.Documents, McpFeatureModules.Coherence, McpFeatureModules.Links, McpFeatureModules.ContentTypes] + ); + + public static McpServerProfile Internal { get; } = new( + "internal", + "Use this server to {capabilities} Elastic Internal Docs: team processes, run books, architecture, and other internal 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. + /// + public void RegisterAllServices(IServiceCollection services) + { + foreach (var module in Modules) + module.RegisterServices(services); + } + + /// + /// 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() + .ToList(); + var toolGuidance = Modules + .SelectMany(m => m.ToolGuidance) + .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 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/Program.cs b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs index 8cbb8a306..b8082b647 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs @@ -3,13 +3,9 @@ // 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; @@ -37,15 +33,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 +50,23 @@ // 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); + + mcpBuilder = profile.Name switch + { + "public" => mcpBuilder + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools(), + "internal" => mcpBuilder + .WithTools() + .WithTools(), + _ => mcpBuilder + }; 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..d4a8991bf --- /dev/null +++ b/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs @@ -0,0 +1,159 @@ +// 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 SemanticSearch over a general web search"); + instructions.Should().Contain("Use GetDocumentByUrl to retrieve a specific page"); + instructions.Should().Contain("Use FindRelatedDocs when exploring what documentation exists"); + instructions.Should().Contain("Use CheckCoherence or FindInconsistencies when reviewing or auditing"); + instructions.Should().Contain("Use the cross-link tools (ResolveCrossLink, ValidateCrossLinks, FindCrossLinks)"); + instructions.Should().Contain("Use ListContentTypes, GetContentTypeGuidelines, and GenerateTemplate 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 Docs: team processes, run books, architecture"); + instructions.Should().Contain("Prefer SemanticSearch over a general web search"); + instructions.Should().Contain("Use GetDocumentByUrl to retrieve a specific page"); + instructions.Should().Contain("Use FindRelatedDocs when exploring what documentation exists"); + instructions.Should().NotContain("CheckCoherence"); + instructions.Should().NotContain("FindInconsistencies"); + instructions.Should().NotContain("ResolveCrossLink"); + instructions.Should().NotContain("ListContentTypes"); + instructions.Should().NotContain("GenerateTemplate"); + } + + [Fact] + public void WhenToUse_IsIdentical_AcrossProfiles() + { + var publicInstructions = McpServerProfile.Public.ComposeServerInstructions(); + var internalInstructions = McpServerProfile.Internal.ComposeServerInstructions(); + + var publicBullets = ExtractBullets(publicInstructions); + var internalBullets = ExtractBullets(internalInstructions); + + foreach (var bullet in internalBullets) + publicBullets.Should().Contain(bullet); + } + + [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 existing documentation pages. + - Needs to check whether a topic is already documented or how it is covered. + - References documentation URLs or Elastic product names such as Elasticsearch, Kibana, Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud. + - Asks about 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 documentation and needs to find related content or check consistency. + - Wants to generate documentation templates following Elastic's content type guidelines. + + + + - Prefer SemanticSearch over a general web search when looking up Elastic documentation content. + - Use FindRelatedDocs when exploring what documentation exists around a topic. + - Use GetDocumentByUrl to retrieve a specific page when the user provides or you already know the URL. + - 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. + + """; + + 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 Docs: team processes, run books, architecture, and other internal knowledge. + + + Use the server when the user: + - Wants to find, read, or verify existing documentation pages. + - Needs to check whether a topic is already documented or how it is covered. + - References documentation 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 FindRelatedDocs when exploring what documentation exists around a topic. + - Use GetDocumentByUrl 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(); +} From 6f003467ff5eb8f2ae8fb7e9447db4d03fe476a6 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri-Benedetti Date: Fri, 27 Feb 2026 12:54:46 +0100 Subject: [PATCH 2/6] Update McpServerInstructionTests.cs --- .../Mcp.Remote.Tests/McpServerInstructionTests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs b/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs index d4a8991bf..e021a75a8 100644 --- a/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs +++ b/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs @@ -99,15 +99,15 @@ 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 this server to search, retrieve, analyze, and author user-facing Elastic product documentation published at elastic.co/docs. Use the server when the user: - - Wants to find, read, or verify existing documentation pages. + - Wants to find, read, or verify existing product documentation pages. - Needs to check whether a topic is already documented or how it is covered. - References documentation URLs or Elastic product names such as Elasticsearch, Kibana, Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud. - Asks about documentation structure, coherence, or inconsistencies across pages. - - Mentions cross-links between documentation repositories (e.g. 'docs-content://path/to/page.md'). + - Mentions cross-links between public documentation repositories (e.g. 'docs-content://path/to/page.md'). - Is writing or editing documentation and needs to find related content or check consistency. - Wants to generate documentation templates following Elastic's content type guidelines. @@ -131,12 +131,12 @@ public void InternalProfile_ComposesExactInstructions() var instructions = McpServerProfile.Internal.ComposeServerInstructions(); var expected = """ - Use this server to search and retrieve Elastic Internal Docs: team processes, run books, architecture, and other internal knowledge. + Use this authenticated server to search and retrieve private Elastic Internal Docs: team processes, run books, architecture, and other internal knowledge. - Use the server when the user: - - Wants to find, read, or verify existing documentation pages. - - Needs to check whether a topic is already documented or how it is covered. + Use the internal docs server when the user: + - Wants to find, read, or verify existing internal documentation pages. + - Needs to check whether a topic is already documented or how it is covered in the internal documentation. - References documentation URLs or Elastic product names such as Elasticsearch, Kibana, Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud. From fdec96fbdef493864d2cf3a1e088e1c4ef215c2a Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 27 Feb 2026 13:26:43 +0100 Subject: [PATCH 3/6] MCP: add profile-based tool name prefixes and context-aware triggers Add ToolNamePrefix, DocsDescription, and ExtraTriggers to McpServerProfile so each profile produces distinct tool names (e.g. public_docs_semantic_search, internal_docs_semantic_search) and profile-specific trigger bullets. Made-with: Cursor --- .../McpFeatureModule.cs | 34 ++++--- .../McpServerProfile.cs | 49 +++++++++- .../McpToolRegistration.cs | 69 +++++++++++++ .../Program.cs | 17 +--- .../McpServerInstructionTests.cs | 96 +++++++++++-------- 5 files changed, 189 insertions(+), 76 deletions(-) create mode 100644 src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs index f74c2b8b3..66f42952c 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs @@ -19,14 +19,16 @@ namespace Elastic.Documentation.Mcp.Remote; /// /// Module identifier. /// Capability verb for the preamble (e.g. "search", "retrieve"). Null if the module does not add a capability. -/// Generic bullet points for the "Use the server when the user:" section. -/// Lines for the tool guidance section. +/// 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, + Type? ToolType, Action RegisterServices ); @@ -37,15 +39,15 @@ internal static class McpFeatureModules Capability: "search", WhenToUse: [ - "Wants to find, read, or verify existing documentation pages.", - "Needs to check whether a topic is already documented or how it is covered.", - "References documentation URLs or Elastic product names such as Elasticsearch, Kibana, Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud." + "Wants to find, read, or verify {docs} pages.", + "Needs to check whether a topic is already covered in {docs}." ], ToolGuidance: [ - "Prefer SemanticSearch over a general web search when looking up Elastic documentation content.", - "Use FindRelatedDocs when exploring what documentation exists around a topic." + "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() ); @@ -55,8 +57,9 @@ internal static class McpFeatureModules WhenToUse: [], ToolGuidance: [ - "Use GetDocumentByUrl to retrieve a specific page when the user provides or you already know the URL." + "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() ); @@ -65,12 +68,13 @@ internal static class McpFeatureModules Capability: "analyze", WhenToUse: [ - "Asks about documentation structure, coherence, or inconsistencies across pages." + "Asks about {docs} structure, coherence, or inconsistencies across pages." ], ToolGuidance: [ - "Use CheckCoherence or FindInconsistencies when reviewing or auditing documentation quality." + "Use {tool:check_coherence} or {tool:find_inconsistencies} when reviewing or auditing documentation quality." ], + ToolType: typeof(CoherenceTools), RegisterServices: _ => { } ); @@ -83,8 +87,9 @@ internal static class McpFeatureModules ], ToolGuidance: [ - "Use the cross-link tools (ResolveCrossLink, ValidateCrossLinks, FindCrossLinks) when working with links between documentation source repositories." + "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()); @@ -98,13 +103,14 @@ internal static class McpFeatureModules Capability: "author", WhenToUse: [ - "Is writing or editing documentation and needs to find related content or check consistency.", - "Wants to generate documentation templates following Elastic's content type guidelines." + "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 ListContentTypes, GetContentTypeGuidelines, and GenerateTemplate when creating new pages." + "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 index d31c707a7..787c4a52c 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs @@ -8,23 +8,38 @@ namespace Elastic.Documentation.Mcp.Remote; /// /// MCP server profile that selects which feature modules are enabled and how the server -/// introduces itself. The introduction frames the server's purpose; module WhenToUse -/// bullets provide generic triggers that gain meaning from this context. +/// 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 Introduction, McpFeatureModule[] 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", - "Use this server to {capabilities} Elastic Internal Docs: team processes, run books, architecture, and other internal knowledge.", + "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] ); @@ -43,12 +58,16 @@ public static McpServerProfile Resolve(string? name) } /// - /// Registers all DI services from enabled modules. + /// 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 { } toolType) + _ = services.AddScoped(toolType); + } } /// @@ -62,9 +81,12 @@ public string ComposeServerInstructions() 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 @@ -84,6 +106,23 @@ public string ComposeServerInstructions() """; } + private static string ReplaceToolPlaceholders(string line, string prefix) + { + // Replace {tool:snake_name} with prefix + snake_name (e.g. {tool:semantic_search} → public_docs_semantic_search) + var result = line; + var start = 0; + while ((start = result.IndexOf("{tool:", start, StringComparison.Ordinal)) >= 0) + { + var end = result.IndexOf('}', start); + if (end < 0) + break; + var snakeName = result[(start + 6)..end]; + result = result[..start] + prefix + snakeName + result[(end + 1)..]; + start += prefix.Length + snakeName.Length; + } + return result; + } + private string DeriveCapabilities() { var verbs = Modules 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..8ae56b8c2 --- /dev/null +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs @@ -0,0 +1,69 @@ +// 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 not { } toolType) + continue; + + var methods = 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(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).ToString() + : char.IsUpper(c) + ? char.ToLowerInvariant(c).ToString() + : c.ToString())); + } +} diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs index b8082b647..fd0ca21ec 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs @@ -3,10 +3,8 @@ // See the LICENSE file in the project root for more information using Elastic.Documentation.Api.Infrastructure.OpenTelemetry; -using Elastic.Documentation.Assembler.Mcp; using Elastic.Documentation.Configuration; using Elastic.Documentation.Mcp.Remote; -using Elastic.Documentation.Mcp.Remote.Tools; using Elastic.Documentation.Search; using Elastic.Documentation.ServiceDefaults; using Microsoft.AspNetCore.Builder; @@ -54,19 +52,8 @@ .AddMcpServer(options => options.ServerInstructions = profile.ComposeServerInstructions()) .WithHttpTransport(o => o.Stateless = true); - mcpBuilder = profile.Name switch - { - "public" => mcpBuilder - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools(), - "internal" => mcpBuilder - .WithTools() - .WithTools(), - _ => mcpBuilder - }; + var prefixedTools = McpToolRegistration.CreatePrefixedTools(profile); + mcpBuilder = mcpBuilder.WithTools(prefixedTools); var app = builder.Build(); diff --git a/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs b/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs index e021a75a8..ade22ce40 100644 --- a/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs +++ b/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs @@ -19,12 +19,12 @@ public void PublicProfile_ContainsAllModuleGuidance() instructions.Should().Contain(""); instructions.Should().Contain("Use the server when the user:"); instructions.Should().Contain(""); - instructions.Should().Contain("Prefer SemanticSearch over a general web search"); - instructions.Should().Contain("Use GetDocumentByUrl to retrieve a specific page"); - instructions.Should().Contain("Use FindRelatedDocs when exploring what documentation exists"); - instructions.Should().Contain("Use CheckCoherence or FindInconsistencies when reviewing or auditing"); - instructions.Should().Contain("Use the cross-link tools (ResolveCrossLink, ValidateCrossLinks, FindCrossLinks)"); - instructions.Should().Contain("Use ListContentTypes, GetContentTypeGuidelines, and GenerateTemplate when creating new pages"); + 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] @@ -33,28 +33,30 @@ public void InternalProfile_ContainsSearchAndDocumentGuidanceOnly() var instructions = McpServerProfile.Internal.ComposeServerInstructions(); instructions.Should().Contain("Use this server to search and retrieve"); - instructions.Should().Contain("Elastic Internal Docs: team processes, run books, architecture"); - instructions.Should().Contain("Prefer SemanticSearch over a general web search"); - instructions.Should().Contain("Use GetDocumentByUrl to retrieve a specific page"); - instructions.Should().Contain("Use FindRelatedDocs when exploring what documentation exists"); - instructions.Should().NotContain("CheckCoherence"); - instructions.Should().NotContain("FindInconsistencies"); - instructions.Should().NotContain("ResolveCrossLink"); - instructions.Should().NotContain("ListContentTypes"); - instructions.Should().NotContain("GenerateTemplate"); + 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 WhenToUse_IsIdentical_AcrossProfiles() + public void Triggers_AreProfileSpecific() { var publicInstructions = McpServerProfile.Public.ComposeServerInstructions(); var internalInstructions = McpServerProfile.Internal.ComposeServerInstructions(); - var publicBullets = ExtractBullets(publicInstructions); - var internalBullets = ExtractBullets(internalInstructions); + publicInstructions.Should().Contain("Elastic documentation pages"); + publicInstructions.Should().Contain("References Elastic product names"); + publicInstructions.Should().NotContain("internal team processes"); - foreach (var bullet in internalBullets) - publicBullets.Should().Contain(bullet); + internalInstructions.Should().Contain("Elastic internal documentation pages"); + internalInstructions.Should().Contain("internal team processes"); + internalInstructions.Should().NotContain("Elastic product names"); } [Fact] @@ -99,26 +101,26 @@ public void PublicProfile_ComposesExactInstructions() var instructions = McpServerProfile.Public.ComposeServerInstructions(); var expected = """ - Use this server to search, retrieve, analyze, and author user-facing Elastic product documentation published at elastic.co/docs. + 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 existing product documentation pages. - - Needs to check whether a topic is already documented or how it is covered. - - References documentation URLs or Elastic product names such as Elasticsearch, Kibana, Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud. - - Asks about documentation structure, coherence, or inconsistencies across pages. - - Mentions cross-links between public documentation repositories (e.g. 'docs-content://path/to/page.md'). - - Is writing or editing documentation and needs to find related content or check consistency. - - Wants to generate documentation templates following Elastic's content type guidelines. + - 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 SemanticSearch over a general web search when looking up Elastic documentation content. - - Use FindRelatedDocs when exploring what documentation exists around a topic. - - Use GetDocumentByUrl to retrieve a specific page when the user provides or you already know the URL. - - 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. + - 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. """; @@ -131,19 +133,19 @@ public void InternalProfile_ComposesExactInstructions() var instructions = McpServerProfile.Internal.ComposeServerInstructions(); var expected = """ - Use this authenticated server to search and retrieve private Elastic Internal Docs: team processes, run books, architecture, and other internal knowledge. + Use this server to search and retrieve Elastic internal documentation: team processes, run books, architecture, and other internal knowledge. - Use the internal docs server when the user: - - Wants to find, read, or verify existing internal documentation pages. - - Needs to check whether a topic is already documented or how it is covered in the internal documentation. - - References documentation URLs or Elastic product names such as Elasticsearch, Kibana, Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud. + 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 SemanticSearch over a general web search when looking up Elastic documentation content. - - Use FindRelatedDocs when exploring what documentation exists around a topic. - - Use GetDocumentByUrl to retrieve a specific page when the user provides or you already know the URL. + - 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. """; @@ -156,4 +158,14 @@ private static List ExtractBullets(string instructions) => .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); + } } From ec21e33c239a0d340a43a4f3ae5927a3b636ecef Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 27 Feb 2026 16:20:10 +0100 Subject: [PATCH 4/6] Fix code quality and AOT trim analysis errors - Use StringBuilder in ReplaceToolPlaceholders instead of string concatenation in loop - Add DynamicallyAccessedMembers annotation to ToolType for AOT compatibility - Replace if/continue with LINQ Where filter in CreatePrefixedTools - Remove redundant ToString() calls in ToSnakeCase Made-with: Cursor --- .../McpFeatureModule.cs | 2 ++ .../McpServerProfile.cs | 21 +++++++++++-------- .../McpToolRegistration.cs | 15 +++++++------ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs index 66f42952c..3eb3c6ee8 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs @@ -2,6 +2,7 @@ // 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; @@ -28,6 +29,7 @@ public sealed record McpFeatureModule( string? Capability, string[] WhenToUse, string[] ToolGuidance, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)] Type? ToolType, Action RegisterServices ); diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs index 787c4a52c..6769f1187 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs @@ -2,6 +2,7 @@ // 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; @@ -108,19 +109,21 @@ public string ComposeServerInstructions() private static string ReplaceToolPlaceholders(string line, string prefix) { - // Replace {tool:snake_name} with prefix + snake_name (e.g. {tool:semantic_search} → public_docs_semantic_search) - var result = line; - var start = 0; - while ((start = result.IndexOf("{tool:", start, StringComparison.Ordinal)) >= 0) + var sb = new StringBuilder(line.Length); + var pos = 0; + int start; + while ((start = line.IndexOf("{tool:", pos, StringComparison.Ordinal)) >= 0) { - var end = result.IndexOf('}', start); + var end = line.IndexOf('}', start); if (end < 0) break; - var snakeName = result[(start + 6)..end]; - result = result[..start] + prefix + snakeName + result[(end + 1)..]; - start += prefix.Length + snakeName.Length; + _ = sb.Append(line, pos, start - pos); + _ = sb.Append(prefix); + _ = sb.Append(line, start + 6, end - start - 6); + pos = end + 1; } - return result; + _ = sb.Append(line, pos, line.Length - pos); + return sb.ToString(); } private string DeriveCapabilities() diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs index 8ae56b8c2..e5aecff86 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs @@ -22,11 +22,12 @@ public static IEnumerable CreatePrefixedTools(McpServerProfile pr var prefix = profile.ToolNamePrefix; var tools = new List(); - foreach (var module in profile.Modules) - { - if (module.ToolType is not { } toolType) - continue; + var modulesWithTools = profile.Modules + .Where(m => m.ToolType is not null) + .Select(m => (module: m, toolType: m.ToolType!)); + foreach (var (_, toolType) in modulesWithTools) + { var methods = toolType .GetMethods(BindingFlags.Public | BindingFlags.Instance) .Where(m => m.GetCustomAttribute() != null); @@ -61,9 +62,7 @@ public static string ToSnakeCase(string value) return string.Concat(value.Select((c, i) => i > 0 && char.IsUpper(c) - ? "_" + char.ToLowerInvariant(c).ToString() - : char.IsUpper(c) - ? char.ToLowerInvariant(c).ToString() - : c.ToString())); + ? $"_{char.ToLowerInvariant(c)}" + : $"{char.ToLowerInvariant(c)}")); } } From d4a8f4ee39a8d497528a3a57fb37b91bee044d12 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 27 Feb 2026 16:58:25 +0100 Subject: [PATCH 5/6] Fix native AOT trim analysis errors and update container base image Add [property:] target to DynamicallyAccessedMembers on McpFeatureModule.ToolType so the annotation flows to the generated property (not just the constructor param). Avoid tuple/local extraction of ToolType in McpToolRegistration and McpServerProfile to preserve trim annotations through direct property access. Update container base image from 9.0-noble-chiseled-aot to 10.0-noble-chiseled to match the net10.0 target framework. Made-with: Cursor --- build/Targets.fs | 2 +- .../McpFeatureModule.cs | 2 +- .../McpServerProfile.cs | 4 ++-- .../McpToolRegistration.cs | 13 ++++++------- 4 files changed, 10 insertions(+), 11 deletions(-) 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/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs index 3eb3c6ee8..e88ae9b26 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs @@ -29,7 +29,7 @@ public sealed record McpFeatureModule( string? Capability, string[] WhenToUse, string[] ToolGuidance, - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)] + [property: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)] Type? ToolType, Action RegisterServices ); diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs index 6769f1187..2c61dfef9 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs @@ -66,8 +66,8 @@ public void RegisterAllServices(IServiceCollection services) foreach (var module in Modules) { module.RegisterServices(services); - if (module.ToolType is { } toolType) - _ = services.AddScoped(toolType); + if (module.ToolType is not null) + _ = services.AddScoped(module.ToolType); } } diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs index e5aecff86..f5010a8d6 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs @@ -22,13 +22,12 @@ public static IEnumerable CreatePrefixedTools(McpServerProfile pr var prefix = profile.ToolNamePrefix; var tools = new List(); - var modulesWithTools = profile.Modules - .Where(m => m.ToolType is not null) - .Select(m => (module: m, toolType: m.ToolType!)); - - foreach (var (_, toolType) in modulesWithTools) + foreach (var module in profile.Modules) { - var methods = toolType + if (module.ToolType is null) + continue; + + var methods = module.ToolType .GetMethods(BindingFlags.Public | BindingFlags.Instance) .Where(m => m.GetCustomAttribute() != null); @@ -41,7 +40,7 @@ public static IEnumerable CreatePrefixedTools(McpServerProfile pr var tool = McpServerTool.Create( method, - ctx => (ctx.Services ?? throw new InvalidOperationException("RequestContext.Services is null")).GetRequiredService(toolType), + ctx => (ctx.Services ?? throw new InvalidOperationException("RequestContext.Services is null")).GetRequiredService(module.ToolType), options); tools.Add(tool); From 7f4e3415cb81c1f116025a8582bdd9bdd0ed6a84 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 27 Feb 2026 19:19:17 +0100 Subject: [PATCH 6/6] Fix IL2069: annotate both constructor param and property for AOT trim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record positional parameter needs [DynamicallyAccessedMembers] on both the parameter (for constructor → backing field assignment) and the property (for callers accessing ToolType). Made-with: Cursor --- src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs index e88ae9b26..6ec251af5 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs @@ -29,6 +29,7 @@ public sealed record McpFeatureModule( string? Capability, string[] WhenToUse, string[] ToolGuidance, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)] [property: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)] Type? ToolType, Action RegisterServices