diff --git a/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs b/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs index e1a2017..f9f33fc 100644 --- a/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs +++ b/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs @@ -8,7 +8,9 @@ public sealed record ClaudeRunConfig( string? AgentPath, string? ResumeSessionId, int? MaxTurns = null, - string? PermissionMode = null + string? PermissionMode = null, + string? McpConfigPath = null, + string? AllowedTools = null ); public sealed class ClaudeArgsBuilder @@ -57,6 +59,12 @@ public sealed class ClaudeArgsBuilder args.Add($"--json-schema {Escape(ResultSchema)}"); + if (config.McpConfigPath is not null) + args.Add($"--mcp-config {Escape(config.McpConfigPath)}"); + + if (config.AllowedTools is not null) + args.Add($"--allowedTools {config.AllowedTools}"); + if (config.ResumeSessionId is not null) args.Add($"--resume {config.ResumeSessionId}"); diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 657bdec..b0f62b6 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; @@ -19,6 +20,7 @@ public sealed class TaskRunner private readonly WorkerConfig _cfg; private readonly ILogger _logger; private readonly ITaskStateService _state; + private readonly TaskRunTokenRegistry _tokens; public TaskRunner( IClaudeProcess claude, @@ -28,7 +30,8 @@ public sealed class TaskRunner ClaudeArgsBuilder argsBuilder, WorkerConfig cfg, ILogger logger, - ITaskStateService state) + ITaskStateService state, + TaskRunTokenRegistry tokens) { _claude = claude; _dbFactory = dbFactory; @@ -38,10 +41,13 @@ public sealed class TaskRunner _cfg = cfg; _logger = logger; _state = state; + _tokens = tokens; } public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct) { + string? mcpToken = null; + string? mcpConfigPath = null; try { ListEntity? list; @@ -75,6 +81,22 @@ public sealed class TaskRunner var resolvedConfig = await ResolveConfigAsync(task, listConfig, null, ct); + // Improvement-eligible runs get a per-run MCP identity so the agent can file + // out-of-scope follow-ups via SuggestImprovement. Children and planning runs do not. + if (task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None) + { + mcpToken = TaskRunTokenRegistry.GenerateToken(); + _tokens.Register(mcpToken, task.Id); + Directory.CreateDirectory(_cfg.LogRoot); + mcpConfigPath = Path.Combine(_cfg.LogRoot, $"{task.Id}_mcp.json"); + await File.WriteAllTextAsync(mcpConfigPath, BuildRunMcpConfigJson(mcpToken), ct); + resolvedConfig = resolvedConfig with + { + McpConfigPath = mcpConfigPath, + AllowedTools = "mcp__claudedo_run__SuggestImprovement", + }; + } + var now = DateTime.UtcNow; await _state.StartRunningAsync(task.Id, now, ct); await _broadcaster.TaskStarted(slot, task.Id, now); @@ -135,6 +157,15 @@ public sealed class TaskRunner _logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id); await MarkFailed(task.Id, task.Title, slot, $"Unhandled error: {ex.Message}"); } + finally + { + if (mcpToken is not null) + { + _tokens.Unregister(mcpToken); + if (mcpConfigPath is not null) + try { File.Delete(mcpConfigPath); } catch { /* best effort */ } + } + } } public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct) @@ -396,6 +427,26 @@ public sealed class TaskRunner } } + private string BuildRunMcpConfigJson(string token) + { + var payload = new + { + mcpServers = new + { + claudedo_run = new + { + type = "http", + url = $"http://127.0.0.1:{_cfg.SignalRPort}/mcp", + headers = new Dictionary + { + ["Authorization"] = $"Bearer {token}", + }, + }, + }, + }; + return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); + } + private async Task ResolveConfigAsync( TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct) { diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 2306b0e..8209537 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -150,7 +150,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, - NullLogger.Instance, state); + NullLogger.Instance, state, new TaskRunTokenRegistry()); var waker = new ClaudeDo.Worker.Queue.QueueWaker(); var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs index 68f4c8e..c3e894d 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs @@ -133,6 +133,18 @@ public sealed class ClaudeArgsBuilderTests Assert.Contains("--permission-mode auto", args); Assert.DoesNotContain("--dangerously-skip-permissions", args); } + + [Fact] + public void Build_emits_mcpConfig_and_allowedTools_when_set() + { + var args = new ClaudeArgsBuilder().Build(new ClaudeRunConfig( + Model: null, SystemPrompt: null, AgentPath: null, ResumeSessionId: null, + McpConfigPath: "C:\\tmp\\t_mcp.json", + AllowedTools: "mcp__claudedo_run__SuggestImprovement")); + Assert.Contains("--mcp-config", args); + Assert.Contains("t_mcp.json", args); + Assert.Contains("--allowedTools mcp__claudedo_run__SuggestImprovement", args); + } } public sealed class MergeInstructionsTests diff --git a/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs index 918a515..fffa820 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs @@ -45,7 +45,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg, - NullLogger.Instance, state); + NullLogger.Instance, state, new TaskRunTokenRegistry()); using (var ctx = _db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("p1"))!, "slot-1", default); @@ -72,7 +72,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new FakeHubContext()), wt, - new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state); + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry()); using (var ctx = _db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("solo"))!, "slot-1", default); diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs index 4d6ebff..3a0e796 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs @@ -55,7 +55,7 @@ public sealed class QueueServiceSlotGuardTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, - NullLogger.Instance, state); + NullLogger.Instance, state, new TaskRunTokenRegistry()); _waker = new QueueWaker(); var picker = new QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index 7b75e9b..a0db50b 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -56,7 +56,7 @@ public sealed class QueueServiceTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, - NullLogger.Instance, state); + NullLogger.Instance, state, new TaskRunTokenRegistry()); _waker = new QueueWaker(); var picker = new QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance);