diff --git a/src/ClaudeDo.Worker/External/ListMcpTools.cs b/src/ClaudeDo.Worker/External/ListMcpTools.cs new file mode 100644 index 0000000..d1884e7 --- /dev/null +++ b/src/ClaudeDo.Worker/External/ListMcpTools.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +public sealed record ListSummaryDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); + +[McpServerToolType] +public sealed class ListMcpTools +{ + private readonly ListRepository _lists; + private readonly HubBroadcaster _broadcaster; + + public ListMcpTools(ListRepository lists, HubBroadcaster broadcaster) + { + _lists = lists; + _broadcaster = broadcaster; + } + + [McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")] + public async Task CreateList( + string name, string? workingDir, string? commitType, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(name)) + throw new InvalidOperationException("name is required."); + + var entity = new ListEntity + { + Id = Guid.NewGuid().ToString(), + Name = name, + WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir, + DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType, + CreatedAt = DateTime.UtcNow, + }; + await _lists.AddAsync(entity, cancellationToken); + await _broadcaster.ListUpdated(entity.Id); + return ToDto(entity); + } + + [McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")] + public async Task UpdateList( + string listId, string? name, string? workingDir, string? commitType, CancellationToken cancellationToken) + { + var entity = await _lists.GetByIdAsync(listId, cancellationToken) + ?? throw new InvalidOperationException($"List {listId} not found."); + + if (name is not null) entity.Name = name; + if (workingDir is not null) + entity.WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir; + if (commitType is not null) + entity.DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType; + + await _lists.UpdateAsync(entity, cancellationToken); + await _broadcaster.ListUpdated(listId); + return ToDto(entity); + } + + [McpServerTool, Description("Delete a list and its tasks. Irreversible.")] + public async Task DeleteList(string listId, CancellationToken cancellationToken) + { + _ = await _lists.GetByIdAsync(listId, cancellationToken) + ?? throw new InvalidOperationException($"List {listId} not found."); + await _lists.DeleteAsync(listId, cancellationToken); + await _broadcaster.ListUpdated(listId); + } + + private static ListSummaryDto ToDto(ListEntity l) => + new(l.Id, l.Name, l.WorkingDir, l.DefaultCommitType); +} diff --git a/tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs new file mode 100644 index 0000000..9f096d1 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs @@ -0,0 +1,91 @@ +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 Microsoft.AspNetCore.SignalR; + +namespace ClaudeDo.Worker.Tests.External; + +internal sealed class ListToolsHubClients : IHubClients +{ + public ListToolsClientProxy Proxy { get; } = new(); + public IClientProxy All => Proxy; + public IClientProxy AllExcept(IReadOnlyList e) => Proxy; + public IClientProxy Client(string c) => Proxy; + public IClientProxy Clients(IReadOnlyList c) => Proxy; + public IClientProxy Group(string g) => Proxy; + public IClientProxy GroupExcept(string g, IReadOnlyList e) => Proxy; + public IClientProxy Groups(IReadOnlyList g) => Proxy; + public IClientProxy User(string u) => Proxy; + public IClientProxy Users(IReadOnlyList u) => Proxy; +} +internal sealed class ListToolsClientProxy : IClientProxy +{ + public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) => Task.CompletedTask; +} +internal sealed class ListToolsHubContext : IHubContext +{ + public ListToolsHubClients RecordingClients { get; } = new(); + public IHubClients Clients => RecordingClients; + public IGroupManager Groups => throw new NotImplementedException(); +} + +public sealed class ListMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly ListRepository _lists; + private readonly ListMcpTools _sut; + + public ListMcpToolsTests() + { + _ctx = _db.CreateContext(); + _lists = new ListRepository(_ctx); + _sut = new ListMcpTools(_lists, new HubBroadcaster(new ListToolsHubContext())); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + [Fact] + public async Task CreateList_PersistsWithDefaults() + { + var dto = await _sut.CreateList("My List", null, null, CancellationToken.None); + + Assert.Equal("My List", dto.Name); + var loaded = await _lists.GetByIdAsync(dto.Id); + Assert.NotNull(loaded); + Assert.Equal("chore", loaded!.DefaultCommitType); + } + + [Fact] + public async Task UpdateList_PatchesNameWorkingDirAndCommitType() + { + var created = await _sut.CreateList("orig", null, null, CancellationToken.None); + + var dto = await _sut.UpdateList(created.Id, "renamed", "C:/work", "feat", CancellationToken.None); + + Assert.Equal("renamed", dto.Name); + Assert.Equal("C:/work", dto.WorkingDir); + var loaded = await _lists.GetByIdAsync(created.Id); + Assert.Equal("feat", loaded!.DefaultCommitType); + } + + [Fact] + public async Task UpdateList_NotFound_Throws() + { + await Assert.ThrowsAsync(() => + _sut.UpdateList("missing", "x", null, null, CancellationToken.None)); + } + + [Fact] + public async Task DeleteList_RemovesList() + { + var created = await _sut.CreateList("gone", null, null, CancellationToken.None); + + await _sut.DeleteList(created.Id, CancellationToken.None); + + Assert.Null(await _lists.GetByIdAsync(created.Id)); + } +}