From f7e946e4723629f8d97e714857f18510944f7ca2 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 22 Jun 2026 17:29:44 +0200 Subject: [PATCH] feat(attachments): MCP tools to attach/list/remove task files AttachmentMcpTools exposes add_task_attachment (text or base64), list_task_attachments, and remove_task_attachment on the external MCP endpoint, so an agent can prepare reference files (plans, scripts) on a task that will run later. Re-attaching the same name overwrites; add/remove refuse on a running task. --- .../Repositories/TaskAttachmentRepository.cs | 6 + .../External/AttachmentMcpTools.cs | 119 +++++++++ src/ClaudeDo.Worker/Program.cs | 6 +- .../External/AttachmentMcpToolsTests.cs | 228 ++++++++++++++++++ .../AttachmentOrphanRecoveryTests.cs | 1 + 5 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 src/ClaudeDo.Worker/External/AttachmentMcpTools.cs create mode 100644 tests/ClaudeDo.Worker.Tests/External/AttachmentMcpToolsTests.cs diff --git a/src/ClaudeDo.Data/Repositories/TaskAttachmentRepository.cs b/src/ClaudeDo.Data/Repositories/TaskAttachmentRepository.cs index 6deb761..28c8381 100644 --- a/src/ClaudeDo.Data/Repositories/TaskAttachmentRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskAttachmentRepository.cs @@ -29,6 +29,12 @@ public sealed class TaskAttachmentRepository .FirstOrDefaultAsync(a => a.TaskId == taskId && a.FileName == fileName, ct); } + public async Task UpdateAsync(TaskAttachmentEntity entity, CancellationToken ct = default) + { + _context.TaskAttachments.Update(entity); + await _context.SaveChangesAsync(ct); + } + public async Task DeleteAsync(string taskId, string fileName, CancellationToken ct = default) { await _context.TaskAttachments diff --git a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs new file mode 100644 index 0000000..940e99c --- /dev/null +++ b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs @@ -0,0 +1,119 @@ +using System.ComponentModel; +using System.Text; +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ModelContextProtocol.Server; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.External; + +public sealed record AttachmentDto(string FileName, long ByteSize, DateTime CreatedAt); + +[McpServerToolType] +public sealed class AttachmentMcpTools +{ + private readonly TaskRepository _tasks; + private readonly TaskAttachmentRepository _attachments; + private readonly AttachmentStore _store; + private readonly HubBroadcaster _broadcaster; + + public AttachmentMcpTools( + TaskRepository tasks, + TaskAttachmentRepository attachments, + AttachmentStore store, + HubBroadcaster broadcaster) + { + _tasks = tasks; + _attachments = attachments; + _store = store; + _broadcaster = broadcaster; + } + + [McpServerTool, Description( + "Attach a read-only reference file to a task. These files are handed to the agent at run time, " + + "making them useful to prepare context for a task that will run later (e.g. plans, scripts, specs). " + + "Pass textContent for plain-text files (plans, markdown, scripts). " + + "Pass base64Content only for binary files (images, archives). Exactly one of the two must be provided. " + + "Re-attaching a file with the same fileName overwrites the previous version. " + + "Refuses if the task is currently Running — cancel it first.")] + public async Task AddTaskAttachment( + string taskId, + string fileName, + string? textContent = null, + string? base64Content = null, + CancellationToken ct = default) + { + var task = await _tasks.GetByIdAsync(taskId, ct) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.Status == TaskStatus.Running) + throw new InvalidOperationException("Cannot add an attachment to a running task. Cancel it first."); + + if (textContent is null == base64Content is null) + throw new InvalidOperationException( + "Exactly one of textContent or base64Content must be provided, not both and not neither."); + + byte[] bytes; + if (textContent is not null) + { + bytes = Encoding.UTF8.GetBytes(textContent); + } + else + { + try { bytes = Convert.FromBase64String(base64Content!); } + catch (FormatException ex) + { + throw new InvalidOperationException("base64Content is not valid Base64.", ex); + } + } + + using var ms = new MemoryStream(bytes); + var byteSize = await _store.SaveAsync(taskId, fileName, ms, ct); + + var existing = await _attachments.GetAsync(taskId, fileName, ct); + if (existing is not null) + { + existing.ByteSize = byteSize; + await _attachments.UpdateAsync(existing, ct); + } + else + { + await _attachments.AddAsync(new TaskAttachmentEntity + { + Id = Guid.NewGuid().ToString(), + TaskId = taskId, + FileName = fileName, + ByteSize = byteSize, + CreatedAt = DateTime.UtcNow, + }, ct); + } + + await _broadcaster.TaskUpdated(taskId); + return new AttachmentDto(fileName, byteSize, existing?.CreatedAt ?? DateTime.UtcNow); + } + + [McpServerTool, Description("List all attachments on a task (fileName, byteSize, createdAt).")] + public async Task> ListTaskAttachments( + string taskId, CancellationToken ct = default) + { + var rows = await _attachments.ListByTaskIdAsync(taskId, ct); + return rows.Select(r => new AttachmentDto(r.FileName, r.ByteSize, r.CreatedAt)).ToList(); + } + + [McpServerTool, Description( + "Remove a single attachment from a task. Deletes both the file on disk and the database record. " + + "Refuses if the task is currently Running — cancel it first.")] + public async Task RemoveTaskAttachment( + string taskId, string fileName, CancellationToken ct = default) + { + var task = await _tasks.GetByIdAsync(taskId, ct) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.Status == TaskStatus.Running) + throw new InvalidOperationException("Cannot remove an attachment from a running task. Cancel it first."); + + _store.DeleteFile(taskId, fileName); + await _attachments.DeleteAsync(taskId, fileName, ct); + await _broadcaster.TaskUpdated(taskId); + } +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 2c34776..92450be 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -256,6 +256,9 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); externalBuilder.Services.AddMcpServer() .WithHttpTransport() .WithTools() @@ -264,7 +267,8 @@ if (cfg.ExternalMcpPort > 0) .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools(); externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}"); externalApp = externalBuilder.Build(); diff --git a/tests/ClaudeDo.Worker.Tests/External/AttachmentMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/AttachmentMcpToolsTests.cs new file mode 100644 index 0000000..b4f519a --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/AttachmentMcpToolsTests.cs @@ -0,0 +1,228 @@ +using System.Text; +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class AttachmentMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly TaskAttachmentRepository _attachments; + private readonly string _storeRoot; + private readonly AttachmentStore _store; + + public AttachmentMcpToolsTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _attachments = new TaskAttachmentRepository(_ctx); + _storeRoot = Path.Combine(Path.GetTempPath(), $"att_test_{Guid.NewGuid():N}"); + _store = new AttachmentStore(_storeRoot); + } + + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + try { Directory.Delete(_storeRoot, recursive: true); } catch { } + } + + private async Task SeedListAsync() + { + var id = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow }); + return id; + } + + private async Task SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle) + { + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "t", + Status = status, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(task); + return task; + } + + private AttachmentMcpTools BuildSut() + { + var hubCtx = new CapturingHubContext(); + var broadcaster = new HubBroadcaster(hubCtx); + return new AttachmentMcpTools(_tasks, _attachments, _store, broadcaster); + } + + [Fact] + public async Task Add_textContent_writes_file_and_db_row() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(); + + var dto = await sut.AddTaskAttachment(task.Id, "plan.md", textContent: "# Hello", ct: CancellationToken.None); + + // File on disk + var filePath = Path.Combine(_storeRoot, task.Id, "plan.md"); + Assert.True(File.Exists(filePath)); + Assert.Equal("# Hello", await File.ReadAllTextAsync(filePath)); + + // DB row + await using var vCtx = _db.CreateContext(); + var row = await new TaskAttachmentRepository(vCtx).GetAsync(task.Id, "plan.md"); + Assert.NotNull(row); + Assert.Equal(Encoding.UTF8.GetByteCount("# Hello"), row!.ByteSize); + Assert.Equal(dto.ByteSize, row.ByteSize); + Assert.Equal("plan.md", dto.FileName); + } + + [Fact] + public async Task Add_base64Content_writes_file_and_db_row() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(); + + var original = new byte[] { 1, 2, 3, 4, 5 }; + var b64 = Convert.ToBase64String(original); + + var dto = await sut.AddTaskAttachment(task.Id, "data.bin", base64Content: b64, ct: CancellationToken.None); + + var filePath = Path.Combine(_storeRoot, task.Id, "data.bin"); + Assert.True(File.Exists(filePath)); + Assert.Equal(original, await File.ReadAllBytesAsync(filePath)); + Assert.Equal(5, dto.ByteSize); + } + + [Fact] + public async Task List_returns_added_items() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(); + + await sut.AddTaskAttachment(task.Id, "a.txt", textContent: "a", ct: CancellationToken.None); + await sut.AddTaskAttachment(task.Id, "b.txt", textContent: "bb", ct: CancellationToken.None); + + var list = await sut.ListTaskAttachments(task.Id); + + Assert.Equal(2, list.Count); + Assert.Contains(list, d => d.FileName == "a.txt"); + Assert.Contains(list, d => d.FileName == "b.txt"); + } + + [Fact] + public async Task Remove_deletes_file_and_row() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(); + + await sut.AddTaskAttachment(task.Id, "remove.txt", textContent: "x", ct: CancellationToken.None); + var filePath = Path.Combine(_storeRoot, task.Id, "remove.txt"); + Assert.True(File.Exists(filePath)); + + await sut.RemoveTaskAttachment(task.Id, "remove.txt"); + + Assert.False(File.Exists(filePath)); + await using var vCtx = _db.CreateContext(); + var row = await new TaskAttachmentRepository(vCtx).GetAsync(task.Id, "remove.txt"); + Assert.Null(row); + } + + [Fact] + public async Task Add_missing_task_throws() + { + var sut = BuildSut(); + + await Assert.ThrowsAsync( + () => sut.AddTaskAttachment("no-such-id", "f.txt", textContent: "x", ct: CancellationToken.None)); + } + + [Fact] + public async Task Add_running_task_throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, TaskStatus.Running); + var sut = BuildSut(); + + await Assert.ThrowsAsync( + () => sut.AddTaskAttachment(task.Id, "f.txt", textContent: "x", ct: CancellationToken.None)); + } + + [Fact] + public async Task Add_neither_content_throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(); + + await Assert.ThrowsAsync( + () => sut.AddTaskAttachment(task.Id, "f.txt", ct: CancellationToken.None)); + } + + [Fact] + public async Task Add_both_content_throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(); + + await Assert.ThrowsAsync( + () => sut.AddTaskAttachment(task.Id, "f.txt", textContent: "x", base64Content: "eA==", ct: CancellationToken.None)); + } + + [Fact] + public async Task Add_invalid_base64_throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(); + + await Assert.ThrowsAsync( + () => sut.AddTaskAttachment(task.Id, "f.bin", base64Content: "!!!not-base64!!!", ct: CancellationToken.None)); + } + + [Fact] + public async Task Readd_same_fileName_overwrites_one_row_updated_size() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(); + + await sut.AddTaskAttachment(task.Id, "plan.md", textContent: "short", ct: CancellationToken.None); + await sut.AddTaskAttachment(task.Id, "plan.md", textContent: "much longer content here", ct: CancellationToken.None); + + // Only one row in DB + await using var vCtx = _db.CreateContext(); + var rows = await new TaskAttachmentRepository(vCtx).ListByTaskIdAsync(task.Id); + Assert.Single(rows); + + // Updated size + var expected = Encoding.UTF8.GetByteCount("much longer content here"); + Assert.Equal(expected, rows[0].ByteSize); + } + + [Fact] + public async Task Remove_running_task_throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, TaskStatus.Running); + var sut = BuildSut(); + + await Assert.ThrowsAsync( + () => sut.RemoveTaskAttachment(task.Id, "f.txt")); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/AttachmentOrphanRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/AttachmentOrphanRecoveryTests.cs index cbd0a13..4480d47 100644 --- a/tests/ClaudeDo.Worker.Tests/Lifecycle/AttachmentOrphanRecoveryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/AttachmentOrphanRecoveryTests.cs @@ -3,6 +3,7 @@ using ClaudeDo.Data.Models; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Lifecycle;