Address buid warnings and linting

Ignore some warnings around log performance (there is no performance issue)
This commit is contained in:
Damian Hickey 2025-10-27 11:02:19 +01:00
parent faf289d67f
commit 44677af0f5
15 changed files with 94 additions and 86 deletions

View file

@ -5,7 +5,7 @@ using System.ComponentModel.DataAnnotations;
namespace Documentation.Mcp.Database;
internal class FTSBlogArticle
internal sealed class FTSBlogArticle
{
[Key]
public required string Id { get; init; }

View file

@ -5,7 +5,7 @@ using System.ComponentModel.DataAnnotations;
namespace Documentation.Mcp.Database;
internal class FTSDocsArticle
internal sealed class FTSDocsArticle
{
[Key]
public required string Id { get; init; }

View file

@ -5,7 +5,7 @@ using System.ComponentModel.DataAnnotations;
namespace Documentation.Mcp.Database;
internal class FTSSampleProject
internal sealed class FTSSampleProject
{
[Key]
public required string Id { get; init; }

View file

@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace Documentation.Mcp.Database;
internal class McpDb(DbContextOptions<McpDb> options) : DbContext(options)
internal sealed class McpDb(DbContextOptions<McpDb> options) : DbContext(options)
{
public DbSet<State> State => Set<State>();
@ -46,11 +46,11 @@ internal class McpDb(DbContextOptions<McpDb> options) : DbContext(options)
public static string? EscapeFtsQueryString(string? query)
=> !string.IsNullOrEmpty(query)
? string.Join(" ", query.Split(' ').Select(q => $"\"{q.Replace("\"", "\"\"")}\""))
? string.Join(" ", query.Split(' ').Select(q => $"\"{q.Replace("\"", "\"\"", StringComparison.OrdinalIgnoreCase)}\""))
: query;
public static string? EscapeFtsQueryString(string? query, string joinWith)
=> !string.IsNullOrEmpty(query)
? string.Join($" {joinWith} ", query.Split(' ').Select(q => $"\"{q.Replace("\"", "\"\"")}\""))
? string.Join($" {joinWith} ", query.Split(' ').Select(q => $"\"{q.Replace("\"", "\"\"", StringComparison.OrdinalIgnoreCase)}\""))
: query;
}

View file

@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Duende.Documentation.Mcp.Server.Database.Migrations;
/// <inheritdoc />
internal partial class Initial : Migration
internal sealed partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)

View file

@ -5,7 +5,7 @@ using System.ComponentModel.DataAnnotations;
namespace Documentation.Mcp.Database;
internal class State
internal sealed class State
{
[Key]
public required string Id { get; init; }

View file

@ -10,6 +10,12 @@
<Title>Duende Documentation MCP Server</Title>
<Copyright>Duende Software</Copyright>
<PackageTags>MCP;ModelContextProtocol;AI;LLM;Duende;IdentityServer</PackageTags>
<!--Use the LoggerMessage delegates - not a high performence scenario-->
<NoWarn>$(NoWarn);CA1848</NoWarn>
<!-- Evaluation of this argument may be expensive and unnecessary if logging is disabled - not a high performance scenario-->
<NoWarn>$(NoWarn);CA1873</NoWarn>
<!-- Avoid uninstantiated internal classes (code analysis) - https://github.com/dotnet/roslyn-analyzers/issues/6561 -->
<NoWarn>$(NoWarn);CA1812</NoWarn>
</PropertyGroup>
<ItemGroup>

View file

@ -3,16 +3,13 @@
namespace Documentation.Mcp.Infrastructure;
internal class TemporaryFileStream : FileStream
internal sealed class TemporaryFileStream : FileStream
{
public static TemporaryFileStream Create()
{
var path = Path.Combine(
Path.GetTempPath(),
Guid.NewGuid() + ".tmp");
private readonly string _path;
private TemporaryFileStream(string path)
: base(path, FileMode.OpenOrCreate, FileAccess.ReadWrite) => _path = path;
return new TemporaryFileStream(path);
}
public static async Task<TemporaryFileStream> CreateFromAsync(Stream otherStream)
{
@ -24,10 +21,15 @@ internal class TemporaryFileStream : FileStream
return temporaryFileStream;
}
private readonly string _path;
private static TemporaryFileStream Create()
{
var path = Path.Combine(
Path.GetTempPath(),
Guid.NewGuid() + ".tmp");
return new TemporaryFileStream(path);
}
private TemporaryFileStream(string path)
: base(path, FileMode.OpenOrCreate, FileAccess.ReadWrite) => _path = path;
protected override void Dispose(bool disposing)
{
@ -37,9 +39,13 @@ internal class TemporaryFileStream : FileStream
{
File.Delete(_path);
}
catch
catch (IOException)
{
// Best-effort...
// Best-effort cleanup - file may be locked or inaccessible
}
catch (UnauthorizedAccessException)
{
// Best-effort cleanup - insufficient permissions
}
}
}

View file

@ -65,7 +65,7 @@ app.MapMcp();
await EnsureDb(app.Services, app.Logger);
app.Run();
await app.RunAsync();
async Task EnsureDb(IServiceProvider services, ILogger logger)
{

View file

@ -12,7 +12,7 @@ using SimpleFeedReader;
namespace Documentation.Mcp.Sources.Blog;
internal class BlogArticleIndexer(IServiceProvider services, ILogger<BlogArticleIndexer> logger) : BackgroundService
internal sealed class BlogArticleIndexer(IServiceProvider services, ILogger<BlogArticleIndexer> logger) : BackgroundService
{
private readonly TimeSpan _maxAge = TimeSpan.FromDays(2);
private static readonly DateTime ReferenceDate = new(2024, 10, 01);
@ -41,7 +41,7 @@ internal class BlogArticleIndexer(IServiceProvider services, ILogger<BlogArticle
var lastUpdate = await db.GetLastUpdateStateAsync("blog");
if (lastUpdate > DateTimeOffset.UtcNow.Add(-_maxAge))
{
logger.LogInformation("Skipping blog indexer, last update was {lastUpdate}", lastUpdate);
logger.LogInformation("Skipping blog indexer, last update was {LastUpdate}", lastUpdate);
return;
}

View file

@ -2,6 +2,7 @@
// See LICENSE in the project root for license information.
using System.ComponentModel;
using System.Globalization;
using System.Text;
using Documentation.Mcp.Database;
using Microsoft.EntityFrameworkCore;
@ -10,7 +11,7 @@ using ModelContextProtocol.Server;
namespace Documentation.Mcp.Sources.Blog;
[McpServerToolType]
internal class BlogSearchTool(McpDb db)
internal sealed class BlogSearchTool(McpDb db)
{
[McpServerTool(Name = "search_duende_blog", Title = "Search Duende Blog")]
[Description("Semantically search within the Duende blog for the given query.")]
@ -24,19 +25,19 @@ internal class BlogSearchTool(McpDb db)
.ToListAsync();
var responseBuilder = new StringBuilder();
responseBuilder.Append($"## Query\n\n{query}\n\n");
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Query\n\n{query}\n\n");
if (results.Count == 0)
{
responseBuilder.Append($"## 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.");
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.");
return responseBuilder.ToString();
}
responseBuilder.Append($"## Response\n\nResults found for: \"{query}\". Listing a document id and document title:\n\n");
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Response\n\nResults found for: \"{query}\". Listing a document id and document title:\n\n");
foreach (var result in results)
{
responseBuilder.Append($"- [{result.Id}]({result.Title})\n");
responseBuilder.Append(CultureInfo.InvariantCulture, $"- [{result.Id}]({result.Title})\n");
}
return responseBuilder.ToString();

View file

@ -12,7 +12,7 @@ using Microsoft.Extensions.Logging;
namespace Documentation.Mcp.Sources.Docs;
internal class DocsArticleIndexer(IServiceProvider services, ILogger<DocsArticleIndexer> logger) : BackgroundService
internal sealed class DocsArticleIndexer(IServiceProvider services, ILogger<DocsArticleIndexer> logger) : BackgroundService
{
private readonly TimeSpan _maxAge = TimeSpan.FromDays(2);
@ -56,9 +56,7 @@ internal class DocsArticleIndexer(IServiceProvider services, ILogger<DocsArticle
if (link.Url?.Contains("_llms-txt/", StringComparison.OrdinalIgnoreCase) == true)
{
var title = link.Title ?? link.FirstChild?.ToString() ?? "Unknown";
var description = link.NextSibling is LiteralInline literal ? literal.Content.Text.TrimStart(':', ' ') : "";
await RunIndexerForDocument(title, description, link.Url!, db, httpClient, stoppingToken);
await RunIndexerForDocument(title, link.Url!, db, httpClient, stoppingToken);
}
}
@ -72,14 +70,14 @@ internal class DocsArticleIndexer(IServiceProvider services, ILogger<DocsArticle
private static async Task RunIndexerForDocument(
string title,
string description,
string linkUrl,
McpDb db,
HttpClient httpClient,
CancellationToken stoppingToken)
{
// Start indexing
var llmsTxt = await httpClient.GetStringAsync(linkUrl, stoppingToken);
var linkUri = new Uri(linkUrl);
var llmsTxt = await httpClient.GetStringAsync(linkUri, stoppingToken);
var llmsMd = Markdig.Markdown.Parse(llmsTxt);
string? articleTitle = null;
@ -108,10 +106,7 @@ internal class DocsArticleIndexer(IServiceProvider services, ILogger<DocsArticle
}
}
if (articleContent != null)
{
articleContent.AppendLine(llmsTxt.Substring(block.Span.Start, block.Span.Length));
}
articleContent?.AppendLine(llmsTxt.Substring(block.Span.Start, block.Span.Length));
}
if (articleTitle != null && articleContent != null)

View file

@ -2,6 +2,7 @@
// See LICENSE in the project root for license information.
using System.ComponentModel;
using System.Globalization;
using System.Text;
using Documentation.Mcp.Database;
using Microsoft.EntityFrameworkCore;
@ -10,7 +11,7 @@ using ModelContextProtocol.Server;
namespace Documentation.Mcp.Sources.Docs;
[McpServerToolType]
internal class DocsSearchTool(McpDb db)
internal sealed class DocsSearchTool(McpDb db)
{
[McpServerTool(Name = "search_duende_docs", Title = "Search Duende Documentation")]
[Description("Semantically search within the Duende documentation for the given query.")]
@ -24,19 +25,19 @@ internal class DocsSearchTool(McpDb db)
.ToListAsync();
var responseBuilder = new StringBuilder();
responseBuilder.Append($"## Query\n\n{query}\n\n");
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Query\n\n{query}\n\n");
if (results.Count == 0)
{
responseBuilder.Append($"## 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.");
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.");
return responseBuilder.ToString();
}
responseBuilder.Append($"## Response\n\nResults found for: \"{query}\". Listing a document id and document title, followed by related product:\n\n");
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Response\n\nResults found for: \"{query}\". Listing a document id and document title, followed by related product:\n\n");
foreach (var result in results)
{
responseBuilder.Append($"- [{result.Id}]({result.Title}) ({result.Product})\n");
responseBuilder.Append(CultureInfo.InvariantCulture, $"- [{result.Id}]({result.Title}) ({result.Product})\n");
}
return responseBuilder.ToString();
@ -52,11 +53,8 @@ internal class DocsSearchTool(McpDb db)
.AsNoTracking()
.FirstOrDefaultAsync();
if (result == null)
{
return $"No data found for document: \"{id}\".";
}
return result.Content;
return result == null
? $"No data found for document: \"{id}\"."
: result.Content;
}
}

View file

@ -15,7 +15,7 @@ using Microsoft.Extensions.Logging;
namespace Documentation.Mcp.Sources.Samples;
internal class SamplesIndexer(IServiceProvider services, ILogger<DocsArticleIndexer> logger) : BackgroundService
internal sealed class SamplesIndexer(IServiceProvider services, ILogger<DocsArticleIndexer> logger) : BackgroundService
{
private readonly TimeSpan _maxAge = TimeSpan.FromDays(7);
@ -43,19 +43,20 @@ internal class SamplesIndexer(IServiceProvider services, ILogger<DocsArticleInde
var lastUpdate = await db.GetLastUpdateStateAsync("samples");
if (lastUpdate > DateTimeOffset.UtcNow.Add(-_maxAge))
{
logger.LogInformation("Skipping samples indexer, last update was {lastUpdate}", lastUpdate);
logger.LogInformation("Skipping samples indexer, last update was {LastUpdate}", lastUpdate);
return;
}
// Explore llms.txt specific to samples
var llmsUri = new Uri("https://docs.duendesoftware.com/_llms-txt/identityserver-sample-code.txt");
var llmsTxt = await httpClient.GetStringAsync(llmsUri, stoppingToken);
llmsTxt = llmsTxt.Replace("###", "\n\n###"); // keep sample titles on separate lines (rehype in docs does minification)
llmsTxt = llmsTxt.Replace("* ", "\n* "); // keep lists on separate lines (rehype in docs does minification)
llmsTxt = llmsTxt.Replace("###", "\n\n###", StringComparison.OrdinalIgnoreCase); // keep sample titles on separate lines (rehype in docs does minification)
llmsTxt = llmsTxt.Replace("* ", "\n* ", StringComparison.OrdinalIgnoreCase); // keep lists on separate lines (rehype in docs does minification)
var llmsMd = Markdig.Markdown.Parse(llmsTxt);
// Download samples repository blob
await using var samplesRepositoryBlobStream = await httpClient.GetStreamAsync("https://github.com/duendesoftware/samples/archive/refs/heads/main.zip", stoppingToken);
var samplesBlobUri = new Uri("https://github.com/duendesoftware/samples/archive/refs/heads/main.zip");
await using var samplesRepositoryBlobStream = await httpClient.GetStreamAsync(samplesBlobUri, stoppingToken);
await using var samplesRepositoryTempStream = await TemporaryFileStream.CreateFromAsync(samplesRepositoryBlobStream);
await using var samplesRepositoryZipStream = new ZipArchive(samplesRepositoryTempStream, ZipArchiveMode.Read, leaveOpen: false);
@ -76,7 +77,7 @@ internal class SamplesIndexer(IServiceProvider services, ILogger<DocsArticleInde
}
else if (sampleContent != null)
{
var files = await GetFilesForRepositoryPathAsync(sampleContent.ToString(), samplesRepositoryZipStream);
var files = await GetFilesForRepositoryPathAsync(sampleContent.ToString(), samplesRepositoryZipStream, stoppingToken);
if (files.Count > 0)
{
db.FTSSampleProject.Add(new FTSSampleProject
@ -108,7 +109,7 @@ internal class SamplesIndexer(IServiceProvider services, ILogger<DocsArticleInde
if (sampleTitle != null && sampleContent != null)
{
var files = await GetFilesForRepositoryPathAsync(sampleContent.ToString(), samplesRepositoryZipStream);
var files = await GetFilesForRepositoryPathAsync(sampleContent.ToString(), samplesRepositoryZipStream, stoppingToken);
if (files.Count > 0)
{
db.FTSSampleProject.Add(new FTSSampleProject
@ -123,27 +124,30 @@ internal class SamplesIndexer(IServiceProvider services, ILogger<DocsArticleInde
}
await db.SaveChangesAsync(stoppingToken);
logger.LogInformation("Saved {count} samples", db.FTSSampleProject.Count());
logger.LogInformation("Saved {Count} samples", db.FTSSampleProject.Count());
await db.SetLastUpdateStateAsync("samples", DateTimeOffset.UtcNow);
logger.LogInformation("Finished samples indexer");
}
private string ExtractTitle(string markdownText)
private static string ExtractTitle(string markdownText)
{
// Remove:
// [Section titled “.......”](#custom-profile-service)
var indexOfSection = markdownText.IndexOf("[Section", StringComparison.OrdinalIgnoreCase);
if (indexOfSection > 0)
{
markdownText = markdownText.Substring(0, indexOfSection - 1);
markdownText = markdownText[..(indexOfSection - 1)];
}
return markdownText;
}
private async Task<List<string>> GetFilesForRepositoryPathAsync(string markdownText, ZipArchive repositoryArchive)
private static async Task<List<string>> GetFilesForRepositoryPathAsync(
string markdownText,
ZipArchive repositoryArchive,
CancellationToken cancellationToken)
{
var files = new List<string>();
@ -160,7 +164,7 @@ internal class SamplesIndexer(IServiceProvider services, ILogger<DocsArticleInde
continue;
}
var sampleRootPath = "samples-main" + link.Url!.Substring(sampleRootIndex);
var sampleRootPath = $"samples-main{link.Url![sampleRootIndex..]}";
var sampleEntries = repositoryArchive.Entries
.Where(e =>
@ -176,8 +180,8 @@ internal class SamplesIndexer(IServiceProvider services, ILogger<DocsArticleInde
foreach (var sampleEntry in sampleEntries)
{
using var sampleEntryStream = new StreamReader(sampleEntry.Open());
var sampleContents = await sampleEntryStream.ReadToEndAsync();
using var sampleEntryStream = new StreamReader(await sampleEntry.OpenAsync(cancellationToken));
var sampleContents = await sampleEntryStream.ReadToEndAsync(cancellationToken);
files.Add("File: `" + sampleEntry.FullName + "`:\n```\n" + sampleContents + "\n```");
}

View file

@ -2,6 +2,7 @@
// See LICENSE in the project root for license information.
using System.ComponentModel;
using System.Globalization;
using System.Text;
using Documentation.Mcp.Database;
using Microsoft.EntityFrameworkCore;
@ -10,7 +11,7 @@ using ModelContextProtocol.Server;
namespace Documentation.Mcp.Sources.Samples;
[McpServerToolType]
internal class SamplesSearchTool(McpDb db)
internal sealed class SamplesSearchTool(McpDb db)
{
[McpServerTool(Name = "search_duende_samples", Title = "Search Duende Code Samples")]
[Description("Search within the Duende code samples for the given query. Use this tool to find recent and relevant C# code samples.")]
@ -24,19 +25,19 @@ internal class SamplesSearchTool(McpDb db)
.ToListAsync();
var responseBuilder = new StringBuilder();
responseBuilder.Append($"## Query\n\n{query}\n\n");
responseBuilder.Append(CultureInfo.InvariantCulture, $"## Query\n\n{query}\n\n");
if (results.Count == 0)
{
responseBuilder.Append($"## 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.");
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.");
return responseBuilder.ToString();
}
responseBuilder.Append($"## 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");
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");
foreach (var result in results)
{
responseBuilder.Append($"- [{result.Id}]({result.Title}) ({result.Product}) - Description: {result.Description}\n");
responseBuilder.Append(CultureInfo.InvariantCulture, $"- [{result.Id}]({result.Title}) ({result.Product}) - Description: {result.Description}\n");
}
return responseBuilder.ToString();
@ -52,17 +53,14 @@ internal class SamplesSearchTool(McpDb db)
.AsNoTracking()
.FirstOrDefaultAsync();
if (result == null)
{
return SampleProject.NotFound();
}
return new SampleProject
{
Title = result.Title,
Description = result.Description,
Files = result.Files.Select(it => new SampleProjectFile { Content = it }).ToList()
};
return result == null
? SampleProject.NotFound()
: new SampleProject
{
Title = result.Title,
Description = result.Description,
Files = [.. result.Files.Select(it => new SampleProjectFile { Content = it })]
};
}
[McpServerTool(Name = "fetch_duende_sample_file", Title = "Fetch a file from a specific sample from Duende Code Samples", UseStructuredContent = true)]
@ -71,7 +69,7 @@ internal class SamplesSearchTool(McpDb db)
[Description("The document id.")] string id,
[Description("The file name.")] string filename)
{
filename = filename.Replace("wwwroot", "~");
filename = filename.Replace("wwwroot", "~", StringComparison.Ordinal);
var result = await db.FTSSampleProject
.FromSqlRaw("SELECT * FROM FTSSampleProject WHERE Id = {0} ORDER BY rank", id)
@ -88,18 +86,18 @@ internal class SamplesSearchTool(McpDb db)
?? SampleProjectFile.NotFound();
}
internal class SampleProject
internal sealed class SampleProject
{
public static SampleProject NotFound() => new SampleProject { Title = "No data found.", Description = "" };
public static SampleProject NotFound() => new() { Title = "No data found.", Description = "" };
public required string Title { get; set; }
public required string Description { get; set; }
public List<SampleProjectFile> Files { get; set; } = new(0);
public List<SampleProjectFile> Files { get; set; } = [];
}
internal class SampleProjectFile
internal sealed class SampleProjectFile
{
public static SampleProjectFile NotFound() => new SampleProjectFile { Content = "No data found." };
public static SampleProjectFile NotFound() => new() { Content = "No data found." };
public required string Content { get; set; }
}