feat(runner): mint per-run MCP token + emit run-scoped --mcp-config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-04 16:03:51 +02:00
parent f3052dc5fc
commit 06e3acd5ac
7 changed files with 78 additions and 7 deletions

View File

@@ -8,7 +8,9 @@ public sealed record ClaudeRunConfig(
string? AgentPath, string? AgentPath,
string? ResumeSessionId, string? ResumeSessionId,
int? MaxTurns = null, int? MaxTurns = null,
string? PermissionMode = null string? PermissionMode = null,
string? McpConfigPath = null,
string? AllowedTools = null
); );
public sealed class ClaudeArgsBuilder public sealed class ClaudeArgsBuilder
@@ -57,6 +59,12 @@ public sealed class ClaudeArgsBuilder
args.Add($"--json-schema {Escape(ResultSchema)}"); 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) if (config.ResumeSessionId is not null)
args.Add($"--resume {config.ResumeSessionId}"); args.Add($"--resume {config.ResumeSessionId}");

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
@@ -19,6 +20,7 @@ public sealed class TaskRunner
private readonly WorkerConfig _cfg; private readonly WorkerConfig _cfg;
private readonly ILogger<TaskRunner> _logger; private readonly ILogger<TaskRunner> _logger;
private readonly ITaskStateService _state; private readonly ITaskStateService _state;
private readonly TaskRunTokenRegistry _tokens;
public TaskRunner( public TaskRunner(
IClaudeProcess claude, IClaudeProcess claude,
@@ -28,7 +30,8 @@ public sealed class TaskRunner
ClaudeArgsBuilder argsBuilder, ClaudeArgsBuilder argsBuilder,
WorkerConfig cfg, WorkerConfig cfg,
ILogger<TaskRunner> logger, ILogger<TaskRunner> logger,
ITaskStateService state) ITaskStateService state,
TaskRunTokenRegistry tokens)
{ {
_claude = claude; _claude = claude;
_dbFactory = dbFactory; _dbFactory = dbFactory;
@@ -38,10 +41,13 @@ public sealed class TaskRunner
_cfg = cfg; _cfg = cfg;
_logger = logger; _logger = logger;
_state = state; _state = state;
_tokens = tokens;
} }
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct) public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
{ {
string? mcpToken = null;
string? mcpConfigPath = null;
try try
{ {
ListEntity? list; ListEntity? list;
@@ -75,6 +81,22 @@ public sealed class TaskRunner
var resolvedConfig = await ResolveConfigAsync(task, listConfig, null, ct); 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; var now = DateTime.UtcNow;
await _state.StartRunningAsync(task.Id, now, ct); await _state.StartRunningAsync(task.Id, now, ct);
await _broadcaster.TaskStarted(slot, task.Id, now); 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); _logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id);
await MarkFailed(task.Id, task.Title, slot, $"Unhandled error: {ex.Message}"); 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) 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<string, string>
{
["Authorization"] = $"Bearer {token}",
},
},
},
};
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
}
private async Task<ClaudeRunConfig> ResolveConfigAsync( private async Task<ClaudeRunConfig> ResolveConfigAsync(
TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct) TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct)
{ {

View File

@@ -150,7 +150,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var argsBuilder = new ClaudeArgsBuilder(); var argsBuilder = new ClaudeArgsBuilder();
var state = TaskStateServiceBuilder.Build(dbFactory).State; var state = TaskStateServiceBuilder.Build(dbFactory).State;
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
NullLogger<TaskRunner>.Instance, state); NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
var waker = new ClaudeDo.Worker.Queue.QueueWaker(); var waker = new ClaudeDo.Worker.Queue.QueueWaker();
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);

View File

@@ -133,6 +133,18 @@ public sealed class ClaudeArgsBuilderTests
Assert.Contains("--permission-mode auto", args); Assert.Contains("--permission-mode auto", args);
Assert.DoesNotContain("--dangerously-skip-permissions", 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 public sealed class MergeInstructionsTests

View File

@@ -45,7 +45,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable
var state = TaskStateServiceBuilder.Build(dbFactory).State; var state = TaskStateServiceBuilder.Build(dbFactory).State;
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance); var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var runner = new TaskRunner(fake, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg, var runner = new TaskRunner(fake, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg,
NullLogger<TaskRunner>.Instance, state); NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
using (var ctx = _db.CreateContext()) using (var ctx = _db.CreateContext())
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("p1"))!, "slot-1", default); 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 state = TaskStateServiceBuilder.Build(dbFactory).State;
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance); var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new FakeHubContext()), wt, var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new FakeHubContext()), wt,
new ClaudeArgsBuilder(), _cfg, NullLogger<TaskRunner>.Instance, state); new ClaudeArgsBuilder(), _cfg, NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
using (var ctx = _db.CreateContext()) using (var ctx = _db.CreateContext())
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("solo"))!, "slot-1", default); await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("solo"))!, "slot-1", default);

View File

@@ -55,7 +55,7 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
var argsBuilder = new ClaudeArgsBuilder(); var argsBuilder = new ClaudeArgsBuilder();
var state = TaskStateServiceBuilder.Build(dbFactory).State; var state = TaskStateServiceBuilder.Build(dbFactory).State;
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance, state); NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
_waker = new QueueWaker(); _waker = new QueueWaker();
var picker = new QueuePicker(dbFactory); var picker = new QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);

View File

@@ -56,7 +56,7 @@ public sealed class QueueServiceTests : IDisposable
var argsBuilder = new ClaudeArgsBuilder(); var argsBuilder = new ClaudeArgsBuilder();
var state = TaskStateServiceBuilder.Build(dbFactory).State; var state = TaskStateServiceBuilder.Build(dbFactory).State;
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance, state); NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
_waker = new QueueWaker(); _waker = new QueueWaker();
var picker = new QueuePicker(dbFactory); var picker = new QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);