From c3493a3a742e688f45e9c5e2f25989a812f13827 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Sat, 30 May 2026 13:54:08 +0200 Subject: [PATCH] feat(worker): add external MCP list/task config tools Co-Authored-By: Claude Opus 4.7 --- .../External/ConfigMcpTools.cs | 66 +++++++++++++++ .../External/ConfigMcpToolsTests.cs | 80 +++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/ClaudeDo.Worker/External/ConfigMcpTools.cs create mode 100644 tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs new file mode 100644 index 0000000..a6f42af --- /dev/null +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -0,0 +1,66 @@ +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 TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath); + +[McpServerToolType] +public sealed class ConfigMcpTools +{ + private readonly ListRepository _lists; + private readonly TaskRepository _tasks; + private readonly HubBroadcaster _broadcaster; + + public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster) + { + _lists = lists; + _tasks = tasks; + _broadcaster = broadcaster; + } + + [McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns null if no config is set.")] + public async Task GetListConfig(string listId, CancellationToken cancellationToken) + { + var cfg = await _lists.GetConfigAsync(listId, cancellationToken); + return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath); + } + + [McpServerTool, Description("Set a list's default model/system prompt/agent path. Passing all three as null clears the list config.")] + public async Task SetListConfig( + string listId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken) + { + _ = await _lists.GetByIdAsync(listId, cancellationToken) + ?? throw new InvalidOperationException($"List {listId} not found."); + + var m = Nullify(model); + var sp = Nullify(systemPrompt); + var ap = Nullify(agentPath); + + if (m is null && sp is null && ap is null) + await _lists.DeleteConfigAsync(listId, cancellationToken); + else + await _lists.SetConfigAsync(new ListConfigEntity + { + ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, + }, cancellationToken); + + await _broadcaster.ListUpdated(listId); + } + + [McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path). Pass null to clear a field.")] + public async Task SetTaskConfig( + string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken) + { + _ = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + + await _tasks.UpdateAgentSettingsAsync(taskId, Nullify(model), Nullify(systemPrompt), Nullify(agentPath), cancellationToken); + await _broadcaster.TaskUpdated(taskId); + } + + private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s; +} diff --git a/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs new file mode 100644 index 0000000..81ee4ce --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs @@ -0,0 +1,80 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class ConfigMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly ListRepository _lists; + private readonly TaskRepository _tasks; + private readonly ConfigMcpTools _sut; + + public ConfigMcpToolsTests() + { + _ctx = _db.CreateContext(); + _lists = new ListRepository(_ctx); + _tasks = new TaskRepository(_ctx); + _sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new CapturingHubContext())); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private async Task SeedListAsync() + { + var id = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow }); + return id; + } + + [Fact] + public async Task SetAndGetListConfig_RoundTrips() + { + var listId = await SeedListAsync(); + + await _sut.SetListConfig(listId, "sonnet", "be terse", null, CancellationToken.None); + var cfg = await _sut.GetListConfig(listId, CancellationToken.None); + + Assert.NotNull(cfg); + Assert.Equal("sonnet", cfg!.Model); + Assert.Equal("be terse", cfg.SystemPrompt); + Assert.Null(cfg.AgentPath); + } + + [Fact] + public async Task SetListConfig_AllNull_ClearsConfig() + { + var listId = await SeedListAsync(); + await _sut.SetListConfig(listId, "sonnet", null, null, CancellationToken.None); + + await _sut.SetListConfig(listId, null, null, null, CancellationToken.None); + + Assert.Null(await _sut.GetListConfig(listId, CancellationToken.None)); + } + + [Fact] + public async Task SetTaskConfig_PersistsOverrides() + { + var listId = await SeedListAsync(); + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "t", + Status = ClaudeDo.Data.Models.TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(task); + + await _sut.SetTaskConfig(task.Id, "opus", null, null, CancellationToken.None); + + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal("opus", loaded!.Model); + } +}