From 2d4288abca4710b92c064b272290d96bac63dee1 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 10 Aug 2026 10:19:45 +0200 Subject: [PATCH] feat(mcp): add save_finding to the external server --- .../External/FindingsMcpTools.cs | 54 +++++++++++++++++++ src/ClaudeDo.Worker/Git/GitExcludeWriter.cs | 13 +++-- src/ClaudeDo.Worker/Git/GitHead.cs | 21 ++++++++ src/ClaudeDo.Worker/Program.cs | 9 +++- 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 src/ClaudeDo.Worker/External/FindingsMcpTools.cs create mode 100644 src/ClaudeDo.Worker/Git/GitHead.cs diff --git a/src/ClaudeDo.Worker/External/FindingsMcpTools.cs b/src/ClaudeDo.Worker/External/FindingsMcpTools.cs new file mode 100644 index 00000000..3f463d31 --- /dev/null +++ b/src/ClaudeDo.Worker/External/FindingsMcpTools.cs @@ -0,0 +1,54 @@ +using System.ComponentModel; +using ClaudeDo.Worker.Findings; +using ClaudeDo.Worker.Git; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +public sealed record SaveFindingResult( + bool Saved, string Slug, string List, string Path, int TotalFindings, bool NearCapacity); + +[McpServerToolType] +public sealed class FindingsMcpTools +{ + private readonly IFindingsStore _store; + private readonly IFindingsStoreLocator _locator; + + public FindingsMcpTools(IFindingsStore store, IFindingsStoreLocator locator) + { + _store = store; + _locator = locator; + } + + [McpServerTool, Description( + "Record a durable trap or invariant you just learned about this codebase, so future sessions " + + "read it instead of rediscovering it — call it the moment you notice one, not at the end of " + + "the session. Admission bar (all three must hold, or don't save it): lasting (still true next " + + "week), non-obvious (a competent reader would get it wrong), and behaviour-changing (\"X looks " + + "like Y but is Z — do W instead\"). A bug you fixed is not a finding, that is git history; " + + "neither is a design decision or a status update. Findings are a scarce, curated list, not a " + + "log — when unsure, don't save it. Re-using a slug overwrites that finding. This is a dumb " + + "write: it does not read the code or generate the finding for you, you already have the " + + "context. nearCapacity=true in the result means the index is getting too long to stay cheap — " + + "prune stale entries before adding more.")] + public async Task SaveFinding( + [Description("Stable lowercase kebab-case id, max 60 chars, e.g. 'conpty-arg-quoting'. Same slug overwrites.")] string slug, + [Description("One full sentence stating the trap itself — this is the index line other agents scan.")] string title, + [Description("2-6 sentences: what happens, why it does not look like that, what to do instead.")] string body, + [Description("Repo-relative path or subsystem the finding applies to, e.g. 'src/ClaudeDo.Worker/Planning'.")] string scope = "", + [Description("List id or name. Optional when exactly one list has a working directory.")] string list = "", + CancellationToken cancellationToken = default) + { + var target = await _locator.ResolveForListAsync(list, cancellationToken); + var head = await GitHead.ShortAsync(target.WorkingDir, cancellationToken); + + var outcome = await _store.SaveAsync( + target.WorkingDir, + new FindingInput(slug, title, body, scope, SourceTaskId: "", VerifiedAgainst: head), + cancellationToken, + target.Tracked); + + return new SaveFindingResult( + true, outcome.Slug, target.ListName, outcome.Path, outcome.TotalFindings, outcome.NearCapacity); + } +} diff --git a/src/ClaudeDo.Worker/Git/GitExcludeWriter.cs b/src/ClaudeDo.Worker/Git/GitExcludeWriter.cs index 0d8fd6ae..bad1fdf3 100644 --- a/src/ClaudeDo.Worker/Git/GitExcludeWriter.cs +++ b/src/ClaudeDo.Worker/Git/GitExcludeWriter.cs @@ -13,7 +13,7 @@ public static class GitExcludeWriter /// Idempotent — an already-present line is left alone. public static async Task AppendLineAsync(string workingDir, string excludeLine, CancellationToken ct) { - var relativeExcludePath = await RunGitCaptureAsync(workingDir, ["rev-parse", "--git-path", "info/exclude"], ct); + var relativeExcludePath = await RevParseAsync(workingDir, ["rev-parse", "--git-path", "info/exclude"], ct); var excludeFile = Path.IsPathRooted(relativeExcludePath) ? relativeExcludePath : Path.GetFullPath(Path.Combine(workingDir, relativeExcludePath)); @@ -32,7 +32,13 @@ public static class GitExcludeWriter await File.AppendAllTextAsync(excludeFile, excludeLine + Environment.NewLine, ct); } - private static async Task RunGitCaptureAsync(string workingDir, IEnumerable args, CancellationToken ct) + /// + /// Runs a `git` invocation and captures stdout. Named for its most common caller + /// (rev-parse), but shared plumbing for any short git query that needs one line of output — + /// non-zero exit throws . Shared by AppendLineAsync + /// above and by GitHead.ShortAsync. + /// + internal static async Task RevParseAsync(string workingDir, IEnumerable args, CancellationToken ct) { var psi = new ProcessStartInfo { @@ -67,7 +73,8 @@ public static class GitExcludeWriter ct.ThrowIfCancellationRequested(); if (proc.ExitCode != 0) - throw new InvalidOperationException($"git rev-parse --git-path failed (exit {proc.ExitCode}): {stderr.TrimEnd()}"); + throw new InvalidOperationException( + $"git {string.Join(' ', args)} failed (exit {proc.ExitCode}): {stderr.TrimEnd()}"); return stdout.TrimEnd('\r', '\n'); } diff --git a/src/ClaudeDo.Worker/Git/GitHead.cs b/src/ClaudeDo.Worker/Git/GitHead.cs new file mode 100644 index 00000000..dfe1350f --- /dev/null +++ b/src/ClaudeDo.Worker/Git/GitHead.cs @@ -0,0 +1,21 @@ +namespace ClaudeDo.Worker.Git; + +/// Short HEAD of a checkout, stamped into a finding's frontmatter so staleness is visible. +public static class GitHead +{ + /// + /// Never throws — a fresh repo with no commits yet, or a working dir that is not a git repo + /// at all, must not block a finding save. Callers get "unknown" instead. + /// + public static async Task ShortAsync(string workingDir, CancellationToken ct) + { + try + { + return await GitExcludeWriter.RevParseAsync(workingDir, ["rev-parse", "--short", "HEAD"], ct); + } + catch (InvalidOperationException) + { + return "unknown"; + } + } +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index c4fd4ed1..7ee48435 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -5,6 +5,7 @@ using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Agents; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Findings; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Logging; @@ -62,6 +63,8 @@ builder.Services.AddDbContextFactory(opt => builder.Services.AddSingleton(cfg); builder.Services.AddSingleton(); +// Singleton so the always-on and in-task MCP servers share the same index write lock. +builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -313,6 +316,9 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); externalBuilder.Services.AddMcpServer() .WithHttpTransport() .WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap)) @@ -327,7 +333,8 @@ if (cfg.ExternalMcpPort > 0) .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools(); externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}"); externalApp = externalBuilder.Build();