feat(mcp): add save_finding to the external server
This commit is contained in:
@@ -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<SaveFindingResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ public static class GitExcludeWriter
|
||||
/// <summary>Idempotent — an already-present line is left alone.</summary>
|
||||
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<string> RunGitCaptureAsync(string workingDir, IEnumerable<string> args, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// 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 <see cref="InvalidOperationException"/>. Shared by AppendLineAsync
|
||||
/// above and by GitHead.ShortAsync.
|
||||
/// </summary>
|
||||
internal static async Task<string> RevParseAsync(string workingDir, IEnumerable<string> 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');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ClaudeDo.Worker.Git;
|
||||
|
||||
/// <summary>Short HEAD of a checkout, stamped into a finding's frontmatter so staleness is visible.</summary>
|
||||
public static class GitHead
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static async Task<string> ShortAsync(string workingDir, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await GitExcludeWriter.RevParseAsync(workingDir, ["rev-parse", "--short", "HEAD"], ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ClaudeDoDbContext>(opt =>
|
||||
|
||||
builder.Services.AddSingleton(cfg);
|
||||
builder.Services.AddSingleton<AttachmentStore>();
|
||||
// Singleton so the always-on and in-task MCP servers share the same index write lock.
|
||||
builder.Services.AddSingleton<IFindingsStore, FindingsStore>();
|
||||
builder.Services.AddHostedService<StaleTaskRecovery>();
|
||||
builder.Services.AddHostedService<OrphanRecovery>();
|
||||
builder.Services.AddHostedService<AttachmentOrphanRecovery>();
|
||||
@@ -313,6 +316,9 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AttachmentStore>());
|
||||
externalBuilder.Services.AddScoped<TaskAttachmentRepository>();
|
||||
externalBuilder.Services.AddScoped<AttachmentMcpTools>();
|
||||
externalBuilder.Services.AddSingleton<IFindingsStore>(app.Services.GetRequiredService<IFindingsStore>());
|
||||
externalBuilder.Services.AddScoped<IFindingsStoreLocator, FindingsStoreLocator>();
|
||||
externalBuilder.Services.AddScoped<FindingsMcpTools>();
|
||||
externalBuilder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
|
||||
@@ -327,7 +333,8 @@ if (cfg.ExternalMcpPort > 0)
|
||||
.WithTools<AppSettingsMcpTools>()
|
||||
.WithTools<TaskWaitMcpTools>()
|
||||
.WithTools<QueueStateMcpTools>()
|
||||
.WithTools<AttachmentMcpTools>();
|
||||
.WithTools<AttachmentMcpTools>()
|
||||
.WithTools<FindingsMcpTools>();
|
||||
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
|
||||
|
||||
externalApp = externalBuilder.Build();
|
||||
|
||||
Reference in New Issue
Block a user