Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build/Targets.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,11 @@ public interface IEnvironmentVariables
/// Reads MCP_ALLOWED_EMAIL_DOMAINS.
/// </summary>
string McpAllowedEmailDomains => GetEnvironmentVariable("MCP_ALLOWED_EMAIL_DOMAINS") ?? "elastic.co";

/// <summary>
/// MCP server profile name (e.g. "public", "internal"). Defaults to "public".
/// Reads MCP_SERVER_PROFILE.
/// </summary>
string McpServerProfile => GetEnvironmentVariable("MCP_SERVER_PROFILE") ?? "public";

}
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,8 @@ public class SystemEnvironmentVariables : IEnvironmentVariables

/// <inheritdoc />
public string McpAllowedEmailDomains => GetEnvironmentVariable("MCP_ALLOWED_EMAIL_DOMAINS") ?? "elastic.co";

/// <inheritdoc />
public string McpServerProfile => GetEnvironmentVariable("MCP_SERVER_PROFILE") ?? "public";

}
119 changes: 119 additions & 0 deletions src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A feature module with DI services and instruction template fragments.
/// WhenToUse bullets should be generic and context-free; the branding introduction frames their meaning.
/// </summary>
/// <param name="Name">Module identifier.</param>
/// <param name="Capability">Capability verb for the preamble (e.g. "search", "retrieve"). Null if the module does not add a capability.</param>
/// <param name="WhenToUse">Bullet points for the "Use the server when the user:" section. Use {docs} for the profile's DocsDescription.</param>
/// <param name="ToolGuidance">Lines for the tool guidance section. Use {tool:snake_case_name} for tool names (e.g. {tool:semantic_search}).</param>
/// <param name="ToolType">The tool class type (e.g. typeof(SearchTools)). Null if the module has no tools.</param>
/// <param name="RegisterServices">DI registrations the module's tools depend on.</param>
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<IServiceCollection> 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<IDocumentGateway, DocumentGateway>()
);

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<ILinkIndexReader>(_ => Aws3LinkIndexReader.CreateAnonymous());
_ = services.AddSingleton<LinksIndexCrossLinkFetcher>();
_ = services.AddSingleton<ILinkUtilService, LinkUtilService>();
}
);

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<ContentTypeProvider>()
);
}
145 changes: 145 additions & 0 deletions src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
/// <param name="Name">Profile identifier (e.g. "public", "internal").</param>
/// <param name="ToolNamePrefix">Prefix for all tool names (e.g. "public_docs_", "internal_docs_").</param>
/// <param name="DocsDescription">Short noun phrase describing this profile's docs (e.g. "Elastic product documentation"). Used to replace {docs} in trigger templates.</param>
/// <param name="Introduction">Introduction template with a {capabilities} placeholder replaced at composition time.</param>
/// <param name="ExtraTriggers">Profile-specific trigger bullets appended after module triggers.</param>
/// <param name="Modules">Enabled feature modules.</param>
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]
);

/// <summary>
/// Resolves a profile by name. Throws if the name is unknown.
/// </summary>
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))
};
}

/// <summary>
/// Registers all DI services from enabled modules, including tool types for resolution at invocation time.
/// </summary>
public void RegisterAllServices(IServiceCollection services)
{
foreach (var module in Modules)
{
module.RegisterServices(services);
if (module.ToolType is not null)
_ = services.AddScoped(module.ToolType);
}
}

/// <summary>
/// Composes server instructions from the profile introduction and enabled module fragments.
/// </summary>
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<tool_guidance>\n" + string.Join("\n", toolGuidance.Select(l => $"- {l}")) + "\n</tool_guidance>"
: "";

return $"""
{introduction}

<triggers>
Use the server when the user:{whenToUseBlock}
</triggers>
{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]
};
}
}
67 changes: 67 additions & 0 deletions src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Creates MCP tools with profile-based name prefixes.
/// </summary>
public static class McpToolRegistration
{
/// <summary>
/// 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.
/// </summary>
public static IEnumerable<McpServerTool> CreatePrefixedTools(McpServerProfile profile)
{
var prefix = profile.ToolNamePrefix;
var tools = new List<McpServerTool>();

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<McpServerToolAttribute>() != 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;
}

/// <summary>
/// Converts PascalCase to snake_case (e.g. SemanticSearch → semantic_search).
/// </summary>
[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)}"));
}
}
Loading
Loading