feat(worker): add external MCP list-management tools
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
72
src/ClaudeDo.Worker/External/ListMcpTools.cs
vendored
Normal file
72
src/ClaudeDo.Worker/External/ListMcpTools.cs
vendored
Normal file
@@ -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<ListSummaryDto> 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<ListSummaryDto> 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);
|
||||||
|
}
|
||||||
91
tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs
vendored
Normal file
91
tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs
vendored
Normal file
@@ -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<string> e) => Proxy;
|
||||||
|
public IClientProxy Client(string c) => Proxy;
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> c) => Proxy;
|
||||||
|
public IClientProxy Group(string g) => Proxy;
|
||||||
|
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => Proxy;
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> g) => Proxy;
|
||||||
|
public IClientProxy User(string u) => Proxy;
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> u) => Proxy;
|
||||||
|
}
|
||||||
|
internal sealed class ListToolsClientProxy : IClientProxy
|
||||||
|
{
|
||||||
|
public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
internal sealed class ListToolsHubContext : IHubContext<WorkerHub>
|
||||||
|
{
|
||||||
|
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<InvalidOperationException>(() =>
|
||||||
|
_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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user