Skip to content

Commit 389fcc5

Browse files
committed
Implement MCP tool methods for docs, blog, and samples search
1 parent 239b986 commit 389fcc5

3 files changed

Lines changed: 199 additions & 6 deletions

File tree

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Copyright (c) Duende Software. All rights reserved.
22
// See LICENSE in the project root for license information.
33

4+
using System.ComponentModel;
5+
using System.Globalization;
6+
using System.Text;
7+
using Docs.Mcp.Database;
8+
using Microsoft.EntityFrameworkCore;
49
using ModelContextProtocol.Server;
510

611
namespace Docs.Mcp.Tools;
@@ -9,7 +14,49 @@ namespace Docs.Mcp.Tools;
914
/// MCP tool for searching Duende blog articles.
1015
/// </summary>
1116
[McpServerToolType]
12-
public sealed class BlogSearchTool
17+
public sealed class BlogSearchTool(McpDb db)
1318
{
14-
// TODO: Implement search_duende_blog and fetch_duende_blog tools
19+
[McpServerTool(Name = "search_duende_blog", Title = "Search Duende Blog")]
20+
[Description("Semantically search within the Duende blog for the given query.")]
21+
public async Task<string> Search(
22+
[Description("The search query. Keep it concise and specific to increase the likelihood of a match.")] string query)
23+
{
24+
var results = await db.FTSBlogArticle
25+
.FromSqlRaw("SELECT * FROM FTSBlogArticle WHERE Title MATCH {0} OR Content MATCH {0} ORDER BY rank", McpDb.EscapeFtsQueryString(query))
26+
.AsNoTracking()
27+
.Take(6)
28+
.ToListAsync();
29+
30+
var responseBuilder = new StringBuilder();
31+
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Query\n\n{query}\n\n");
32+
33+
if (results.Count == 0)
34+
{
35+
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Response\n\nNo results found for: \"{query}\"\n\nIf you'd like to retry the search, try changing the query to increase the likelihood of a match.");
36+
return responseBuilder.ToString();
37+
}
38+
39+
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Response\n\nResults found for: \"{query}\". Listing a document id and document title:\n\n");
40+
41+
foreach (var result in results)
42+
{
43+
responseBuilder.Append(CultureInfo.InvariantCulture, $"- [{result.Id}]({result.Title})\n");
44+
}
45+
46+
return responseBuilder.ToString();
47+
}
48+
49+
[McpServerTool(Name = "fetch_duende_blog", Title = "Fetch specific article from Duende blog")]
50+
[Description("Fetch a specific article from the Duende blog.")]
51+
public async Task<string> Fetch([Description("The document id.")] string id)
52+
{
53+
var result = await db.FTSBlogArticle
54+
.FromSqlRaw("SELECT * FROM FTSBlogArticle WHERE Id = {0} ORDER BY rank", id)
55+
.AsNoTracking()
56+
.FirstOrDefaultAsync();
57+
58+
return result == null
59+
? $"No data found for document: \"{id}\"."
60+
: $"# {result.Title}\n\n{result.Content}";
61+
}
1562
}
Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Copyright (c) Duende Software. All rights reserved.
22
// See LICENSE in the project root for license information.
33

4+
using System.ComponentModel;
5+
using System.Globalization;
6+
using System.Text;
7+
using Docs.Mcp.Database;
8+
using Microsoft.EntityFrameworkCore;
49
using ModelContextProtocol.Server;
510

611
namespace Docs.Mcp.Tools;
@@ -9,7 +14,50 @@ namespace Docs.Mcp.Tools;
914
/// MCP tool for searching Duende documentation articles.
1015
/// </summary>
1116
[McpServerToolType]
12-
public sealed class DocsSearchTool
17+
public sealed class DocsSearchTool(McpDb db)
1318
{
14-
// TODO: Implement search_duende_docs and fetch_duende_docs tools
19+
[McpServerTool(Name = "search_duende_docs", Title = "Search Duende Documentation")]
20+
[Description("Semantically search within the Duende documentation for the given query.")]
21+
public async Task<string> Search(
22+
[Description("The search query. Keep it concise and specific to increase the likelihood of a match.")] string query)
23+
{
24+
var results = await db.FTSDocsArticle
25+
.FromSqlRaw("SELECT * FROM FTSDocsArticle WHERE Title MATCH {0} OR Content MATCH {0} OR Product MATCH {0} ORDER BY rank", McpDb.EscapeFtsQueryString(query))
26+
.AsNoTracking()
27+
.Take(6)
28+
.ToListAsync();
29+
30+
var responseBuilder = new StringBuilder();
31+
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Query\n\n{query}\n\n");
32+
33+
if (results.Count == 0)
34+
{
35+
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Response\n\nNo results found for: \"{query}\"\n\nIf you'd like to retry the search, try changing the query to increase the likelihood of a match.");
36+
return responseBuilder.ToString();
37+
}
38+
39+
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Response\n\nResults found for: \"{query}\". Listing a document id and document title, followed by related product:\n\n");
40+
41+
foreach (var result in results)
42+
{
43+
responseBuilder.Append(CultureInfo.InvariantCulture, $"- [{result.Id}]({result.Title}) ({result.Product})\n");
44+
}
45+
46+
return responseBuilder.ToString();
47+
}
48+
49+
[McpServerTool(Name = "fetch_duende_docs", Title = "Fetch specific article from Duende Documentation")]
50+
[Description("Fetch a specific article from the Duende documentation.")]
51+
public async Task<string> Fetch(
52+
[Description("The document id.")] string id)
53+
{
54+
var result = await db.FTSDocsArticle
55+
.FromSqlRaw("SELECT * FROM FTSDocsArticle WHERE Id = {0} ORDER BY rank", id)
56+
.AsNoTracking()
57+
.FirstOrDefaultAsync();
58+
59+
return result == null
60+
? $"No data found for document: \"{id}\"."
61+
: result.Content;
62+
}
1563
}
Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Copyright (c) Duende Software. All rights reserved.
22
// See LICENSE in the project root for license information.
33

4+
using System.ComponentModel;
5+
using System.Globalization;
6+
using System.Text;
7+
using Docs.Mcp.Database;
8+
using Microsoft.EntityFrameworkCore;
49
using ModelContextProtocol.Server;
510

611
namespace Docs.Mcp.Tools;
@@ -9,7 +14,100 @@ namespace Docs.Mcp.Tools;
914
/// MCP tool for searching Duende code samples.
1015
/// </summary>
1116
[McpServerToolType]
12-
public sealed class SamplesSearchTool
17+
public sealed class SamplesSearchTool(McpDb db)
1318
{
14-
// TODO: Implement search_duende_samples, fetch_duende_sample, fetch_duende_sample_file tools
19+
[McpServerTool(Name = "search_duende_samples", Title = "Search Duende Code Samples")]
20+
[Description("Search within the Duende code samples for the given query. Use this tool to find recent and relevant C# code samples.")]
21+
public async Task<string> Search(
22+
[Description("The search query. Keep it concise and specific to increase the likelihood of a match.")] string query)
23+
{
24+
var results = await db.FTSSampleProject
25+
.FromSqlRaw("SELECT * FROM FTSSampleProject WHERE Title MATCH {0} OR Description MATCH {0} OR Product MATCH {0} ORDER BY rank", McpDb.EscapeFtsQueryString(query, "OR"))
26+
.AsNoTracking()
27+
.Take(6)
28+
.ToListAsync();
29+
30+
var responseBuilder = new StringBuilder();
31+
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Query\n\n{query}\n\n");
32+
33+
if (results.Count == 0)
34+
{
35+
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Response\n\nNo results found for: \"{query}\"\n\nIf you'd like to retry the search, try changing the query to increase the likelihood of a match.");
36+
return responseBuilder.ToString();
37+
}
38+
39+
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Response\n\nResults found for: \"{query}\". Listing a document id and document title, followed by related product and a description of the sample:\n\n");
40+
41+
foreach (var result in results)
42+
{
43+
responseBuilder.Append(CultureInfo.InvariantCulture, $"- [{result.Id}]({result.Title}) ({result.Product}) - Description: {result.Description}\n");
44+
}
45+
46+
return responseBuilder.ToString();
47+
}
48+
49+
[McpServerTool(Name = "fetch_duende_sample", Title = "Fetch specific sample from Duende Code Samples", UseStructuredContent = true)]
50+
[Description("Fetch a specific sample from the Duende Code Samples. The result contains a title, description, and the sample code in a list of files.")]
51+
public async Task<SampleProject> Fetch(
52+
[Description("The document id.")] string id)
53+
{
54+
var result = await db.FTSSampleProject
55+
.FromSqlRaw("SELECT * FROM FTSSampleProject WHERE Id = {0} ORDER BY rank", id)
56+
.AsNoTracking()
57+
.FirstOrDefaultAsync();
58+
59+
return result == null
60+
? SampleProject.NotFound()
61+
: new SampleProject
62+
{
63+
Title = result.Title,
64+
Description = result.Description,
65+
Files = [.. result.Files.Select(it => new SampleProjectFile { Content = it })]
66+
};
67+
}
68+
69+
[McpServerTool(Name = "fetch_duende_sample_file", Title = "Fetch a file from a specific sample from Duende Code Samples", UseStructuredContent = true)]
70+
[Description("Fetch a file from specific sample from the Duende Code Samples.")]
71+
public async Task<SampleProjectFile> FetchFile(
72+
[Description("The document id.")] string id,
73+
[Description("The file name.")] string filename)
74+
{
75+
filename = filename.Replace("wwwroot", "~", StringComparison.Ordinal);
76+
77+
var result = await db.FTSSampleProject
78+
.FromSqlRaw("SELECT * FROM FTSSampleProject WHERE Id = {0} ORDER BY rank", id)
79+
.AsNoTracking()
80+
.FirstOrDefaultAsync();
81+
82+
if (result == null)
83+
{
84+
return SampleProjectFile.NotFound();
85+
}
86+
87+
var files = result.Files.Select(it => new SampleProjectFile { Content = it }).ToList();
88+
return files.FirstOrDefault(it => it.Content.Contains(filename, StringComparison.OrdinalIgnoreCase))
89+
?? SampleProjectFile.NotFound();
90+
}
91+
92+
/// <summary>
93+
/// Represents a sample project with its metadata and files.
94+
/// </summary>
95+
public sealed class SampleProject
96+
{
97+
public static SampleProject NotFound() => new() { Title = "No data found.", Description = "" };
98+
99+
public required string Title { get; set; }
100+
public required string Description { get; set; }
101+
public List<SampleProjectFile> Files { get; set; } = [];
102+
}
103+
104+
/// <summary>
105+
/// Represents a single file within a sample project.
106+
/// </summary>
107+
public sealed class SampleProjectFile
108+
{
109+
public static SampleProjectFile NotFound() => new() { Content = "No data found." };
110+
111+
public required string Content { get; set; }
112+
}
15113
}

0 commit comments

Comments
 (0)