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
8 changes: 7 additions & 1 deletion docs/mcp/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@

The MCP server currently exposes the following tools:

### Cross-link tools

| Tool | Description |
|------|-------------|
| `Echo` | Echoes a greeting message back to the client. |
| `ResolveCrossLink` | Resolves a cross-link (like `docs-content://get-started/intro.md`) to its target URL and returns available anchors. |
| `ListRepositories` | Lists all repositories available in the cross-link index with their metadata. |
| `GetRepositoryLinks` | Gets all pages and their anchors published by a specific repository. |
| `FindCrossLinks` | Finds all cross-links between repositories. Can filter by source or target repository. |
| `ValidateCrossLinks` | Validates cross-links to a repository and reports any broken links. |

## Configuration

Expand Down
16 changes: 0 additions & 16 deletions src/Elastic.Documentation.Mcp/EchoTool.cs

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,9 @@
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\services\Elastic.Documentation.Assembler\Elastic.Documentation.Assembler.csproj" />
</ItemGroup>

</Project>

198 changes: 198 additions & 0 deletions src/Elastic.Documentation.Mcp/LinkTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// 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.ComponentModel;
using System.Text.Json;
using Elastic.Documentation.Assembler.Links;
using Elastic.Documentation.Mcp.Responses;
using ModelContextProtocol.Server;

namespace Elastic.Documentation.Mcp;

[McpServerToolType]
public class LinkTools(ILinkUtilService linkUtilService)
{
/// <summary>
/// Resolves a cross-link URI to its target URL.
/// </summary>
[McpServerTool, Description("Resolves a cross-link (like 'docs-content://get-started/intro.md') to its target URL and returns available anchors.")]
public async Task<string> ResolveCrossLink(
[Description("The cross-link URI to resolve (e.g., 'docs-content://get-started/intro.md')")] string crossLink,
CancellationToken cancellationToken = default)
{
try
{
var result = await linkUtilService.ResolveCrossLinkAsync(crossLink, cancellationToken);

if (result.IsSuccess)
{
var value = result.Value;
return JsonSerializer.Serialize(
new CrossLinkResolved(value.ResolvedUrl, value.Repository, value.Path, value.Anchors, value.Fragment),
McpJsonContext.Default.CrossLinkResolved);
}

return JsonSerializer.Serialize(
new ErrorResponse(result.Error.Message, result.Error.Details, result.Error.AvailableRepositories),
McpJsonContext.Default.ErrorResponse);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException)
{
return JsonSerializer.Serialize(new ErrorResponse(ex.Message), McpJsonContext.Default.ErrorResponse);
}
}

/// <summary>
/// Lists all available repositories in the link index.
/// </summary>
[McpServerTool, Description("Lists all repositories available in the cross-link index with their metadata.")]
public async Task<string> ListRepositories(CancellationToken cancellationToken = default)
{
try
{
var result = await linkUtilService.ListRepositoriesAsync(cancellationToken);

if (result.IsSuccess)
{
var value = result.Value;
var repos = value.Repositories.Select(r =>
new Responses.RepositoryInfo(r.Repository, r.Branch, r.Path, r.GitRef, r.UpdatedAt)).ToList();
return JsonSerializer.Serialize(
new ListRepositoriesResponse(value.Count, repos),
McpJsonContext.Default.ListRepositoriesResponse);
}

return JsonSerializer.Serialize(
new ErrorResponse(result.Error.Message, result.Error.Details, result.Error.AvailableRepositories),
McpJsonContext.Default.ErrorResponse);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException)
{
return JsonSerializer.Serialize(new ErrorResponse(ex.Message), McpJsonContext.Default.ErrorResponse);
}
}

/// <summary>
/// Gets all links published by a repository.
/// </summary>
[McpServerTool, Description("Gets all pages and their anchors published by a specific repository.")]
public async Task<string> GetRepositoryLinks(
[Description("The repository name (e.g., 'docs-content', 'elasticsearch')")] string repository,
CancellationToken cancellationToken = default)
{
try
{
var result = await linkUtilService.GetRepositoryLinksAsync(repository, cancellationToken);

if (result.IsSuccess)
{
var value = result.Value;
var pages = value.Pages.Select(p =>
new Responses.PageInfo(p.Path, p.Anchors, p.Hidden)).ToList();
return JsonSerializer.Serialize(
new RepositoryLinksResponse(
value.Repository,
new Responses.OriginInfo(value.Origin.RepositoryName, value.Origin.GitRef),
value.UrlPathPrefix,
value.PageCount,
value.CrossLinkCount,
pages),
McpJsonContext.Default.RepositoryLinksResponse);
}

return JsonSerializer.Serialize(
new ErrorResponse(result.Error.Message, result.Error.Details, result.Error.AvailableRepositories),
McpJsonContext.Default.ErrorResponse);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException)
{
return JsonSerializer.Serialize(new ErrorResponse(ex.Message), McpJsonContext.Default.ErrorResponse);
}
}

/// <summary>
/// Finds all cross-links from one repository to another.
/// </summary>
[McpServerTool, Description("Finds all cross-links between repositories. Can filter by source or target repository.")]
public async Task<string> FindCrossLinks(
[Description("Source repository to find links FROM (optional)")] string? from = null,
[Description("Target repository to find links TO (optional)")] string? to = null,
CancellationToken cancellationToken = default)
{
try
{
var result = await linkUtilService.FindCrossLinksAsync(from, to, cancellationToken);

if (result.IsSuccess)
{
var value = result.Value;
var links = value.Links.Select(l =>
new Responses.CrossLinkInfo(l.FromRepository, l.ToRepository, l.Link)).ToList();
return JsonSerializer.Serialize(
new FindCrossLinksResponse(value.Count, links),
McpJsonContext.Default.FindCrossLinksResponse);
}

return JsonSerializer.Serialize(
new ErrorResponse(result.Error.Message, result.Error.Details, result.Error.AvailableRepositories),
McpJsonContext.Default.ErrorResponse);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException)
{
return JsonSerializer.Serialize(new ErrorResponse(ex.Message), McpJsonContext.Default.ErrorResponse);
}
}

/// <summary>
/// Validates cross-links and finds broken ones.
/// </summary>
[McpServerTool, Description("Validates cross-links to a repository and reports any broken links.")]
public async Task<string> ValidateCrossLinks(
[Description("Target repository to validate links TO (e.g., 'docs-content')")] string repository,
CancellationToken cancellationToken = default)
{
try
{
var result = await linkUtilService.ValidateCrossLinksAsync(repository, cancellationToken);

if (result.IsSuccess)
{
var value = result.Value;
var broken = value.Broken.Select(b =>
new Responses.BrokenLinkInfo(b.FromRepository, b.Link, b.Errors)).ToList();
return JsonSerializer.Serialize(
new ValidateCrossLinksResponse(value.Repository, value.ValidLinks, value.BrokenLinks, broken),
McpJsonContext.Default.ValidateCrossLinksResponse);
}

return JsonSerializer.Serialize(
new ErrorResponse(result.Error.Message, result.Error.Details, result.Error.AvailableRepositories),
McpJsonContext.Default.ErrorResponse);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException)
{
return JsonSerializer.Serialize(new ErrorResponse(ex.Message), McpJsonContext.Default.ErrorResponse);
}
}
}
8 changes: 7 additions & 1 deletion src/Elastic.Documentation.Mcp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
// 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.LinkIndex;
using Elastic.Documentation.Links.InboundLinks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
Expand All @@ -10,10 +13,13 @@
builder.Logging.AddConsole(options =>
options.LogToStandardErrorThreshold = LogLevel.Trace);

builder.Services.AddSingleton<ILinkIndexReader>(_ => Aws3LinkIndexReader.CreateAnonymous());
builder.Services.AddSingleton<LinksIndexCrossLinkFetcher>();
builder.Services.AddSingleton<ILinkUtilService, LinkUtilService>();

builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();

await builder.Build().RunAsync();

39 changes: 39 additions & 0 deletions src/Elastic.Documentation.Mcp/Responses.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// 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.Json.Serialization;

namespace Elastic.Documentation.Mcp.Responses;

// Common error response
public sealed record ErrorResponse(string Error, List<string>? Details = null, List<string>? AvailableRepositories = null);

// ResolveCrossLink response
public sealed record CrossLinkResolved(string Resolved, string Repository, string Path, string[]? Anchors, string Fragment);

// ListRepositories response
public sealed record RepositoryInfo(string Repository, string Branch, string Path, string GitRef, DateTimeOffset UpdatedAt);
public sealed record ListRepositoriesResponse(int Count, List<RepositoryInfo> Repositories);

// GetRepositoryLinks response
public sealed record OriginInfo(string RepositoryName, string GitRef);
public sealed record PageInfo(string Path, string[]? Anchors, bool Hidden);
public sealed record RepositoryLinksResponse(string Repository, OriginInfo Origin, string? UrlPathPrefix, int PageCount, int CrossLinkCount, List<PageInfo> Pages);

// FindCrossLinks response
public sealed record CrossLinkInfo(string FromRepository, string ToRepository, string Link);
public sealed record FindCrossLinksResponse(int Count, List<CrossLinkInfo> Links);

// ValidateCrossLinks response
public sealed record BrokenLinkInfo(string FromRepository, string Link, List<string> Errors);
public sealed record ValidateCrossLinksResponse(string Repository, int ValidLinks, int BrokenLinks, List<BrokenLinkInfo> Broken);

[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(ErrorResponse))]
[JsonSerializable(typeof(CrossLinkResolved))]
[JsonSerializable(typeof(ListRepositoriesResponse))]
[JsonSerializable(typeof(RepositoryLinksResponse))]
[JsonSerializable(typeof(FindCrossLinksResponse))]
[JsonSerializable(typeof(ValidateCrossLinksResponse))]
public sealed partial class McpJsonContext : JsonSerializerContext;
Loading
Loading