diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs index 6ef4b0d..7f35b24 100644 --- a/src/ClaudeDo.Worker/Config/WorkerConfig.cs +++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs @@ -31,6 +31,14 @@ public sealed class WorkerConfig [JsonPropertyName("claude_bin")] public string ClaudeBin { get; set; } = "claude"; + /// Port for the external MCP endpoint. 0 disables the external listener entirely. + [JsonPropertyName("external_mcp_port")] + public int ExternalMcpPort { get; set; } = 47_822; + + /// Optional API key clients must pass via X-ClaudeDo-Key header. Null/empty = loopback trust only. + [JsonPropertyName("external_mcp_api_key")] + public string? ExternalMcpApiKey { get; set; } + public static string DefaultConfigPath => Path.Combine(Paths.AppDataRoot(), "worker.config.json"); diff --git a/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs b/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs new file mode 100644 index 0000000..38b90ff --- /dev/null +++ b/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs @@ -0,0 +1,32 @@ +using ClaudeDo.Worker.Config; +using Microsoft.AspNetCore.Http; + +namespace ClaudeDo.Worker.External; + +public sealed class ExternalMcpAuthMiddleware +{ + private const string HeaderName = "X-ClaudeDo-Key"; + + private readonly RequestDelegate _next; + + public ExternalMcpAuthMiddleware(RequestDelegate next) => _next = next; + + public async Task InvokeAsync(HttpContext ctx, WorkerConfig cfg) + { + if (string.IsNullOrEmpty(cfg.ExternalMcpApiKey)) + { + await _next(ctx); + return; + } + + var provided = ctx.Request.Headers[HeaderName].ToString(); + if (!string.Equals(provided, cfg.ExternalMcpApiKey, StringComparison.Ordinal)) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync($"Missing or invalid {HeaderName} header"); + return; + } + + await _next(ctx); + } +} diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs new file mode 100644 index 0000000..bbe13ed --- /dev/null +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -0,0 +1,197 @@ +using System.ComponentModel; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Services; +using ModelContextProtocol.Server; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.External; + +public sealed record TaskListDto(string Id, string Name, string? WorkingDir); + +public sealed record TaskDto( + string Id, + string ListId, + string Title, + string? Description, + string Status, + string? Result, + string? CreatedBy, + DateTime CreatedAt, + DateTime? StartedAt, + DateTime? FinishedAt); + +[McpServerToolType] +public sealed class ExternalMcpService +{ + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly QueueService _queue; + private readonly HubBroadcaster _broadcaster; + + public ExternalMcpService( + TaskRepository tasks, + ListRepository lists, + QueueService queue, + HubBroadcaster broadcaster) + { + _tasks = tasks; + _lists = lists; + _queue = queue; + _broadcaster = broadcaster; + } + + [McpServerTool, Description("List all task lists available in ClaudeDo.")] + public async Task> ListTaskLists(CancellationToken cancellationToken) + { + var lists = await _lists.GetAllAsync(cancellationToken); + return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList(); + } + + [McpServerTool, Description("List tasks in a given list. Optionally filter by creator (CreatedBy) and/or status.")] + public async Task> ListTasks( + string listId, + string? createdBy, + string? status, + CancellationToken cancellationToken) + { + TaskStatus? statusFilter = null; + if (!string.IsNullOrWhiteSpace(status)) + { + if (!Enum.TryParse(status, ignoreCase: true, out var parsed)) + throw new InvalidOperationException($"Unknown status '{status}'."); + statusFilter = parsed; + } + + var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken); + IEnumerable query = tasks; + if (createdBy is not null) + query = query.Where(t => t.CreatedBy == createdBy); + if (statusFilter is not null) + query = query.Where(t => t.Status == statusFilter); + + return query.Select(ToDto).ToList(); + } + + [McpServerTool, Description("Get a single task by id, including its current status and result.")] + public async Task GetTask(string taskId, CancellationToken cancellationToken) + { + var task = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + return ToDto(task); + } + + [McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution.")] + public async Task AddTask( + string listId, + string title, + string? description, + string createdBy, + bool queueImmediately, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(listId)) + throw new InvalidOperationException("listId is required."); + if (string.IsNullOrWhiteSpace(title)) + throw new InvalidOperationException("title is required."); + if (string.IsNullOrWhiteSpace(createdBy)) + throw new InvalidOperationException("createdBy is required."); + + var list = await _lists.GetByIdAsync(listId, cancellationToken) + ?? throw new InvalidOperationException($"List {listId} not found."); + + var entity = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = title, + Description = description, + Status = queueImmediately ? TaskStatus.Queued : TaskStatus.Manual, + CreatedAt = DateTime.UtcNow, + CommitType = list.DefaultCommitType, + CreatedBy = createdBy, + }; + await _tasks.AddAsync(entity, cancellationToken); + + if (queueImmediately) + _queue.WakeQueue(); + + await _broadcaster.TaskUpdated(entity.Id); + return ToDto(entity); + } + + [McpServerTool, Description("Update a task's status. Only 'Manual' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")] + public async Task UpdateTaskStatus( + string taskId, + string status, + CancellationToken cancellationToken) + { + if (!Enum.TryParse(status, ignoreCase: true, out var target)) + throw new InvalidOperationException($"Unknown status '{status}'."); + + var task = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + + switch (target) + { + case TaskStatus.Manual: + await _tasks.ResetToManualAsync(taskId, cancellationToken); + break; + + case TaskStatus.Queued: + if (task.Status is TaskStatus.Running) + throw new InvalidOperationException("Cannot enqueue a running task."); + task.Status = TaskStatus.Queued; + await _tasks.UpdateAsync(task, cancellationToken); + _queue.WakeQueue(); + break; + + default: + throw new InvalidOperationException( + $"Status '{target}' is not settable externally. Use RunTaskNow or CancelTask."); + } + + var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; + await _broadcaster.TaskUpdated(taskId); + return ToDto(reload); + } + + [McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")] + public async Task RunTaskNow(string taskId, CancellationToken cancellationToken) + { + try + { + await _queue.RunNow(taskId); + } + catch (InvalidOperationException) + { + throw new InvalidOperationException("Override slot busy. Try again later."); + } + catch (KeyNotFoundException) + { + throw new InvalidOperationException($"Task {taskId} not found."); + } + await _broadcaster.TaskUpdated(taskId); + } + + [McpServerTool, Description("Cancel a running task. Returns true if the task was running and cancellation was requested.")] + public async Task CancelTask(string taskId, CancellationToken cancellationToken) + { + var cancelled = _queue.CancelTask(taskId); + if (cancelled) await _broadcaster.TaskUpdated(taskId); + return cancelled; + } + + private static TaskDto ToDto(TaskEntity t) => new( + t.Id, + t.ListId, + t.Title, + t.Description, + t.Status.ToString(), + t.Result, + t.CreatedBy, + t.CreatedAt, + t.StartedAt, + t.FinishedAt); +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index cefec7e..cfb768e 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -2,6 +2,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.External; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Runner; @@ -72,6 +73,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(sp => sp.GetRequiredService>().CreateDbContext()); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddMcpServer() .WithHttpTransport() @@ -108,4 +110,44 @@ app.MapMcp("/mcp"); app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})", cfg.SignalRPort, cfg.DbPath); -app.Run(); +// Build the external MCP endpoint as a separate WebApplication on its own port. +// Rationale: ModelContextProtocol.AspNetCore registers one server per DI container, +// so we need a second app to expose a different tool set under different auth. +// Shared singletons (QueueService, HubBroadcaster, WorkerConfig, db factory) are +// injected by instance so both apps operate on the same runtime state. +WebApplication? externalApp = null; +if (cfg.ExternalMcpPort > 0) +{ + var externalBuilder = WebApplication.CreateBuilder(); + externalBuilder.Services.AddSingleton(cfg); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService>()); + externalBuilder.Services.AddScoped(sp => + sp.GetRequiredService>().CreateDbContext()); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools(); + externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}"); + + externalApp = externalBuilder.Build(); + externalApp.UseMiddleware(); + externalApp.MapMcp("/mcp"); + + externalApp.Logger.LogInformation( + "ClaudeDo.Worker external MCP listening on http://127.0.0.1:{Port} (auth: {Auth})", + cfg.ExternalMcpPort, + string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key"); +} + +if (externalApp is null) +{ + await app.RunAsync(); +} +else +{ + await Task.WhenAll(app.RunAsync(), externalApp.RunAsync()); +}