Skip to content

Commit 9003feb

Browse files
committed
Add MCP server for AI-powered documentation search
Implement Model Context Protocol (MCP) endpoint at /mcp to enable AI assistants to search and retrieve documentation, blog posts, and code samples using full-text search. New projects: - Docs.Mcp: Shared library with SQLite FTS5 database and MCP tools - Docs.Indexer: Build-time CLI tool to index content Features: - 7 MCP tools: search/fetch for docs, blog, and samples - Build-time indexing (not runtime) for fast container startup - Skip indexing locally if database exists (use --force to rebuild) - mcp-index build target integrated into build and container targets
1 parent 8c09d89 commit 9003feb

22 files changed

Lines changed: 1232 additions & 8 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
astro/dist/
33
astro/root/
44
server/Docs.Web/wwwroot/
5+
6+
# MCP index database (generated at build time)
7+
server/Docs.Web/data/
58
# generated types
69
astro/.astro/
710

README.md

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@ All commands use the `build.cs` file-based build script and can be run from any
3737

3838
| Command | Action |
3939
| :------ | :----- |
40-
| `dotnet build.cs` | Build everything (Astro + .NET) |
40+
| `dotnet build.cs` | Build everything (Astro + .NET + MCP index) |
4141
| `dotnet build.cs astro-build` | Build Astro to wwwroot |
4242
| `dotnet build.cs dotnet-build` | Build .NET solution |
4343
| `dotnet build.cs container` | Build container image |
44+
| `dotnet build.cs mcp-index` | Build MCP search index database |
4445
| `dotnet build.cs aspire` | Start Aspire dev environment |
4546
| `dotnet build.cs clean` | Clean all build outputs |
4647
| `dotnet build.cs verify-formatting` | Check .NET code formatting |
@@ -64,6 +65,41 @@ docker run -p 8080:8080 docs
6465

6566
The site will be available at http://localhost:8080.
6667

68+
## MCP Server
69+
70+
The documentation site exposes a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) endpoint at `/mcp` for AI-powered search. This enables AI assistants to search and retrieve documentation, blog posts, and code samples.
71+
72+
### Available Tools
73+
74+
| Tool | Description |
75+
| :--- | :---------- |
76+
| `search_duende_docs` | Full-text search across documentation |
77+
| `fetch_duende_docs` | Retrieve a specific documentation article |
78+
| `search_duende_blog` | Full-text search across blog posts |
79+
| `fetch_duende_blog` | Retrieve a specific blog post |
80+
| `search_duende_samples` | Full-text search across code samples |
81+
| `fetch_duende_sample` | Get sample project metadata and file list |
82+
| `fetch_duende_sample_file` | Retrieve a specific file from a sample |
83+
84+
### Building the Index
85+
86+
The MCP search index is built at build time (not runtime) using the `mcp-index` target:
87+
88+
```bash
89+
dotnet build.cs mcp-index
90+
```
91+
92+
This creates a SQLite FTS5 database at `server/Docs.Web/data/mcp.db` containing:
93+
- Documentation articles (from Astro's `_llms-txt/*.txt` output)
94+
- Blog posts (fetched from RSS feed)
95+
- Code samples (downloaded from GitHub)
96+
97+
The index is automatically built as part of the `build` and `container` targets. Locally, subsequent runs skip indexing if the database already exists. Use `--force` to rebuild:
98+
99+
```bash
100+
dotnet run --project server/Docs.Indexer -- --wwwroot <path> --output <path> --force
101+
```
102+
67103
## Project Structure
68104

69105
This project uses Astro + Starlight for the documentation site, served by ASP.NET Core in production.
@@ -82,8 +118,11 @@ This project uses Astro + Starlight for the documentation site, served by ASP.NE
82118
│ ├── package.json
83119
│ └── tsconfig.json
84120
└── server/ # ASP.NET Core server
85-
├── Docs.Web/ # Static file server
86-
│ └── wwwroot/ # Astro build output (gitignored)
121+
├── Docs.Web/ # Static file server + MCP endpoint
122+
│ ├── wwwroot/ # Astro build output (gitignored)
123+
│ └── data/ # MCP database (gitignored)
124+
├── Docs.Mcp/ # MCP server library (search tools)
125+
├── Docs.Indexer/ # Build-time search indexer
87126
├── Docs.AppHost/ # .NET Aspire orchestrator
88127
└── Docs.ServiceDefaults/ # Shared configuration
89128
```

build.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
const string LinkCheck = "link-check";
2828
const string VerifyFormatting = "verify-formatting";
2929
const string Clean = "clean";
30+
const string McpIndex = "mcp-index";
3031
const string Default = "default";
3132

3233
// Restore
@@ -64,10 +65,10 @@ await RunAsync("docker",
6465
RunAsync("dotnet", "publish Docs.Web/Docs.Web.csproj -c Release --no-restore",
6566
workingDirectory: serverDir));
6667

67-
Target(Build, dependsOn: [AstroBuild, DotnetBuild]);
68+
Target(Build, dependsOn: [AstroBuild, DotnetBuild, McpIndex]);
6869

6970
// Container (no Dockerfile needed!)
70-
Target(Container, dependsOn: [AstroBuild], () =>
71+
Target(Container, dependsOn: [AstroBuild, McpIndex], () =>
7172
RunAsync("dotnet", "publish Docs.Web/Docs.Web.csproj -c Release /t:PublishContainer",
7273
workingDirectory: serverDir));
7374

@@ -83,12 +84,25 @@ await RunAsync("docker",
8384
RunAsync("dotnet", "format Docs.slnx --verify-no-changes --no-restore",
8485
workingDirectory: serverDir));
8586

87+
// MCP Index - build the search database
88+
var mcpDataDir = Path.Combine(serverDir, "Docs.Web", "data");
89+
Target(McpIndex, dependsOn: [AstroBuild, Restore], async () =>
90+
{
91+
Directory.CreateDirectory(mcpDataDir);
92+
var mcpDbPath = Path.Combine(mcpDataDir, "mcp.db");
93+
await RunAsync("dotnet",
94+
$"run --project Docs.Indexer -- --wwwroot \"{wwwrootDir}\" --output \"{mcpDbPath}\"",
95+
workingDirectory: serverDir);
96+
});
97+
8698
// Clean
8799
Target(Clean, async () =>
88100
{
89101
await RunAsync("dotnet", "clean Docs.slnx", workingDirectory: serverDir);
90102
if (Directory.Exists(wwwrootDir))
91103
Directory.Delete(wwwrootDir, recursive: true);
104+
if (Directory.Exists(mcpDataDir))
105+
Directory.Delete(mcpDataDir, recursive: true);
92106
});
93107

94108
Target(Default, dependsOn: [Build]);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net10.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Markdig" Version="0.41.0" />
12+
<PackageReference Include="ReverseMarkdown" Version="4.6.0" />
13+
<PackageReference Include="SimpleFeedReader" Version="2.0.0" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<ProjectReference Include="..\Docs.Mcp\Docs.Mcp.csproj" />
18+
</ItemGroup>
19+
20+
</Project>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) Duende Software. All rights reserved.
2+
// See LICENSE in the project root for license information.
3+
4+
using Docs.Mcp.Database;
5+
using HtmlAgilityPack;
6+
using Microsoft.EntityFrameworkCore;
7+
using ReverseMarkdown;
8+
using SimpleFeedReader;
9+
10+
namespace Docs.Indexer.Indexers;
11+
12+
/// <summary>
13+
/// Indexes blog articles from the Duende Software RSS feed.
14+
/// </summary>
15+
public sealed class BlogIndexer(McpDb db, HttpClient httpClient)
16+
{
17+
private const string RssFeedUrl = "https://duendesoftware.com/rss.xml";
18+
private static readonly DateTime ReferenceDate = new(2024, 10, 01);
19+
20+
/// <summary>
21+
/// Fetch and index blog articles from the RSS feed.
22+
/// </summary>
23+
public async Task IndexAsync()
24+
{
25+
Console.WriteLine($"Fetching RSS feed: {RssFeedUrl}");
26+
27+
var reader = new FeedReader();
28+
var items = await reader.RetrieveFeedAsync(RssFeedUrl);
29+
30+
// Filter to blog posts since the reference date
31+
var blogItems = items
32+
.Where(it => it.PublishDate >= ReferenceDate && it.Categories?.Contains("blog") == true)
33+
.ToList();
34+
35+
Console.WriteLine($"Found {blogItems.Count} blog posts since {ReferenceDate:yyyy-MM-dd}");
36+
37+
var indexedCount = 0;
38+
foreach (var item in blogItems)
39+
{
40+
if (item.Uri == null)
41+
{
42+
continue;
43+
}
44+
45+
try
46+
{
47+
await IndexBlogPostAsync(item.Title ?? "Untitled", item.GetSummary(), item.Uri);
48+
indexedCount++;
49+
Console.WriteLine($" Indexed: {item.Title}");
50+
}
51+
catch (Exception ex)
52+
{
53+
Console.WriteLine($" Error indexing {item.Title}: {ex.Message}");
54+
throw;
55+
}
56+
}
57+
58+
await db.SaveChangesAsync();
59+
Console.WriteLine($"Indexed {indexedCount} blog articles");
60+
}
61+
62+
private async Task IndexBlogPostAsync(string title, string? description, Uri url)
63+
{
64+
// Fetch the HTML content
65+
var htmlContent = await httpClient.GetStringAsync(url);
66+
67+
// Parse HTML and find content section
68+
var htmlDocument = new HtmlDocument();
69+
htmlDocument.LoadHtml(htmlContent);
70+
71+
// Try to find the main content section
72+
var content = htmlDocument.DocumentNode.SelectSingleNode("//section[@class='page-content alt markdown']")
73+
?? htmlDocument.DocumentNode.SelectSingleNode("//article")
74+
?? htmlDocument.DocumentNode.SelectSingleNode("//main");
75+
76+
if (content == null)
77+
{
78+
Console.WriteLine($" Warning: Could not find content section for {title}");
79+
return;
80+
}
81+
82+
// Convert HTML to Markdown
83+
var converter = new Converter(new Config
84+
{
85+
GithubFlavored = true,
86+
RemoveComments = true
87+
});
88+
89+
var markdownContent = converter.Convert(content.InnerHtml);
90+
91+
// Combine description with content if available
92+
var fullContent = !string.IsNullOrEmpty(description)
93+
? $"Summary: {description}\n\n---\n\n{markdownContent}"
94+
: markdownContent;
95+
96+
await db.Database.ExecuteSqlRawAsync(
97+
"INSERT INTO FTSBlogArticle (Id, Title, Content) VALUES ({0}, {1}, {2})",
98+
Guid.NewGuid().ToString(),
99+
title,
100+
fullContent);
101+
}
102+
}

0 commit comments

Comments
 (0)