diff --git a/docs-mcp/src/Documentation.Mcp/Database/FTSBlogArticle.cs b/docs-mcp/src/Documentation.Mcp/Database/FTSBlogArticle.cs index c989b8491..1d44260e9 100644 --- a/docs-mcp/src/Documentation.Mcp/Database/FTSBlogArticle.cs +++ b/docs-mcp/src/Documentation.Mcp/Database/FTSBlogArticle.cs @@ -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; } diff --git a/docs-mcp/src/Documentation.Mcp/Database/FTSDocsArticle.cs b/docs-mcp/src/Documentation.Mcp/Database/FTSDocsArticle.cs index a4606cbb3..c95244ab5 100644 --- a/docs-mcp/src/Documentation.Mcp/Database/FTSDocsArticle.cs +++ b/docs-mcp/src/Documentation.Mcp/Database/FTSDocsArticle.cs @@ -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; } diff --git a/docs-mcp/src/Documentation.Mcp/Database/FTSSampleProject.cs b/docs-mcp/src/Documentation.Mcp/Database/FTSSampleProject.cs index 7621289c0..56ba35853 100644 --- a/docs-mcp/src/Documentation.Mcp/Database/FTSSampleProject.cs +++ b/docs-mcp/src/Documentation.Mcp/Database/FTSSampleProject.cs @@ -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; } diff --git a/docs-mcp/src/Documentation.Mcp/Database/McpDb.cs b/docs-mcp/src/Documentation.Mcp/Database/McpDb.cs index b4c899b22..b88c9d666 100644 --- a/docs-mcp/src/Documentation.Mcp/Database/McpDb.cs +++ b/docs-mcp/src/Documentation.Mcp/Database/McpDb.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore; namespace Documentation.Mcp.Database; -internal class McpDb(DbContextOptions options) : DbContext(options) +internal sealed class McpDb(DbContextOptions options) : DbContext(options) { public DbSet State => Set(); @@ -46,11 +46,11 @@ internal class McpDb(DbContextOptions 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; } diff --git a/docs-mcp/src/Documentation.Mcp/Database/Migrations/20250828191400_Initial.cs b/docs-mcp/src/Documentation.Mcp/Database/Migrations/20250828191400_Initial.cs index 0f2e1613d..03e5ca9b3 100644 --- a/docs-mcp/src/Documentation.Mcp/Database/Migrations/20250828191400_Initial.cs +++ b/docs-mcp/src/Documentation.Mcp/Database/Migrations/20250828191400_Initial.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Duende.Documentation.Mcp.Server.Database.Migrations; /// -internal partial class Initial : Migration +internal sealed partial class Initial : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) diff --git a/docs-mcp/src/Documentation.Mcp/Database/State.cs b/docs-mcp/src/Documentation.Mcp/Database/State.cs index 13899973c..573cec266 100644 --- a/docs-mcp/src/Documentation.Mcp/Database/State.cs +++ b/docs-mcp/src/Documentation.Mcp/Database/State.cs @@ -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; } diff --git a/docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj b/docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj index 4c08335d3..fa5c978de 100644 --- a/docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj +++ b/docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj @@ -10,6 +10,12 @@ Duende Documentation MCP Server Duende Software MCP;ModelContextProtocol;AI;LLM;Duende;IdentityServer + + $(NoWarn);CA1848 + + $(NoWarn);CA1873 + + $(NoWarn);CA1812 diff --git a/docs-mcp/src/Documentation.Mcp/Infrastructure/TemporaryFileStream.cs b/docs-mcp/src/Documentation.Mcp/Infrastructure/TemporaryFileStream.cs index e4c76257e..298b24f87 100644 --- a/docs-mcp/src/Documentation.Mcp/Infrastructure/TemporaryFileStream.cs +++ b/docs-mcp/src/Documentation.Mcp/Infrastructure/TemporaryFileStream.cs @@ -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 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 } } } diff --git a/docs-mcp/src/Documentation.Mcp/Program.cs b/docs-mcp/src/Documentation.Mcp/Program.cs index dc247c2b8..85d915888 100644 --- a/docs-mcp/src/Documentation.Mcp/Program.cs +++ b/docs-mcp/src/Documentation.Mcp/Program.cs @@ -65,7 +65,7 @@ app.MapMcp(); await EnsureDb(app.Services, app.Logger); -app.Run(); +await app.RunAsync(); async Task EnsureDb(IServiceProvider services, ILogger logger) { diff --git a/docs-mcp/src/Documentation.Mcp/Sources/Blog/BlogArticleIndexer.cs b/docs-mcp/src/Documentation.Mcp/Sources/Blog/BlogArticleIndexer.cs index 323509055..ccdbd9093 100644 --- a/docs-mcp/src/Documentation.Mcp/Sources/Blog/BlogArticleIndexer.cs +++ b/docs-mcp/src/Documentation.Mcp/Sources/Blog/BlogArticleIndexer.cs @@ -12,7 +12,7 @@ using SimpleFeedReader; namespace Documentation.Mcp.Sources.Blog; -internal class BlogArticleIndexer(IServiceProvider services, ILogger logger) : BackgroundService +internal sealed class BlogArticleIndexer(IServiceProvider services, ILogger 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 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; } diff --git a/docs-mcp/src/Documentation.Mcp/Sources/Blog/BlogSearchTool.cs b/docs-mcp/src/Documentation.Mcp/Sources/Blog/BlogSearchTool.cs index 4f5da5dc2..e4da5a338 100644 --- a/docs-mcp/src/Documentation.Mcp/Sources/Blog/BlogSearchTool.cs +++ b/docs-mcp/src/Documentation.Mcp/Sources/Blog/BlogSearchTool.cs @@ -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(); diff --git a/docs-mcp/src/Documentation.Mcp/Sources/Docs/DocsArticleIndexer.cs b/docs-mcp/src/Documentation.Mcp/Sources/Docs/DocsArticleIndexer.cs index 436377e59..27becacd7 100644 --- a/docs-mcp/src/Documentation.Mcp/Sources/Docs/DocsArticleIndexer.cs +++ b/docs-mcp/src/Documentation.Mcp/Sources/Docs/DocsArticleIndexer.cs @@ -12,7 +12,7 @@ using Microsoft.Extensions.Logging; namespace Documentation.Mcp.Sources.Docs; -internal class DocsArticleIndexer(IServiceProvider services, ILogger logger) : BackgroundService +internal sealed class DocsArticleIndexer(IServiceProvider services, ILogger logger) : BackgroundService { private readonly TimeSpan _maxAge = TimeSpan.FromDays(2); @@ -56,9 +56,7 @@ internal class DocsArticleIndexer(IServiceProvider services, ILogger logger) : BackgroundService +internal sealed class SamplesIndexer(IServiceProvider services, ILogger logger) : BackgroundService { private readonly TimeSpan _maxAge = TimeSpan.FromDays(7); @@ -43,19 +43,20 @@ internal class SamplesIndexer(IServiceProvider services, ILogger 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 0) { db.FTSSampleProject.Add(new FTSSampleProject @@ -108,7 +109,7 @@ internal class SamplesIndexer(IServiceProvider services, ILogger 0) { db.FTSSampleProject.Add(new FTSSampleProject @@ -123,27 +124,30 @@ internal class SamplesIndexer(IServiceProvider services, ILogger 0) { - markdownText = markdownText.Substring(0, indexOfSection - 1); + markdownText = markdownText[..(indexOfSection - 1)]; } return markdownText; } - private async Task> GetFilesForRepositoryPathAsync(string markdownText, ZipArchive repositoryArchive) + private static async Task> GetFilesForRepositoryPathAsync( + string markdownText, + ZipArchive repositoryArchive, + CancellationToken cancellationToken) { var files = new List(); @@ -160,7 +164,7 @@ internal class SamplesIndexer(IServiceProvider services, ILogger @@ -176,8 +180,8 @@ internal class SamplesIndexer(IServiceProvider services, ILogger 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 Files { get; set; } = new(0); + public List 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; } }