feat(worker): add external MCP endpoint with API-key auth

A second WebApplication runs the external MCP server on its own port (default 47822) so it can expose a different tool set under different auth than the internal /mcp endpoint. Shared singletons (config, broadcaster, queue, db factory) are injected by instance so both apps share runtime state. ExternalMcpAuthMiddleware enforces an optional X-ClaudeDo-Key header; loopback-only trust when no key is configured.

Tools: ListTaskLists, ListTasks, GetTask, AddTask, UpdateTaskStatus, RunTaskNow, CancelTask.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-25 09:36:46 +02:00
parent 16e1ddd129
commit 45320427e8
4 changed files with 280 additions and 1 deletions

View File

@@ -31,6 +31,14 @@ public sealed class WorkerConfig
[JsonPropertyName("claude_bin")] [JsonPropertyName("claude_bin")]
public string ClaudeBin { get; set; } = "claude"; public string ClaudeBin { get; set; } = "claude";
/// <summary>Port for the external MCP endpoint. 0 disables the external listener entirely.</summary>
[JsonPropertyName("external_mcp_port")]
public int ExternalMcpPort { get; set; } = 47_822;
/// <summary>Optional API key clients must pass via X-ClaudeDo-Key header. Null/empty = loopback trust only.</summary>
[JsonPropertyName("external_mcp_api_key")]
public string? ExternalMcpApiKey { get; set; }
public static string DefaultConfigPath => public static string DefaultConfigPath =>
Path.Combine(Paths.AppDataRoot(), "worker.config.json"); Path.Combine(Paths.AppDataRoot(), "worker.config.json");

View File

@@ -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);
}
}

View File

@@ -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<IReadOnlyList<TaskListDto>> 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<IReadOnlyList<TaskDto>> ListTasks(
string listId,
string? createdBy,
string? status,
CancellationToken cancellationToken)
{
TaskStatus? statusFilter = null;
if (!string.IsNullOrWhiteSpace(status))
{
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
throw new InvalidOperationException($"Unknown status '{status}'.");
statusFilter = parsed;
}
var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken);
IEnumerable<TaskEntity> 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<TaskDto> 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<TaskDto> 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<TaskDto> UpdateTaskStatus(
string taskId,
string status,
CancellationToken cancellationToken)
{
if (!Enum.TryParse<TaskStatus>(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<bool> 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);
}

View File

@@ -2,6 +2,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Git; using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Runner;
@@ -72,6 +73,7 @@ builder.Services.AddScoped<PlanningMcpContextAccessor>();
builder.Services.AddScoped<ClaudeDoDbContext>(sp => builder.Services.AddScoped<ClaudeDoDbContext>(sp =>
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext()); sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
builder.Services.AddScoped<TaskRepository>(); builder.Services.AddScoped<TaskRepository>();
builder.Services.AddScoped<ListRepository>();
builder.Services.AddScoped<PlanningMcpService>(); builder.Services.AddScoped<PlanningMcpService>();
builder.Services.AddMcpServer() builder.Services.AddMcpServer()
.WithHttpTransport() .WithHttpTransport()
@@ -108,4 +110,44 @@ app.MapMcp("/mcp");
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})", app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
cfg.SignalRPort, cfg.DbPath); 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<HubBroadcaster>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<QueueService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>());
externalBuilder.Services.AddScoped<ClaudeDoDbContext>(sp =>
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
externalBuilder.Services.AddScoped<TaskRepository>();
externalBuilder.Services.AddScoped<ListRepository>();
externalBuilder.Services.AddScoped<ExternalMcpService>();
externalBuilder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools<ExternalMcpService>();
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
externalApp = externalBuilder.Build();
externalApp.UseMiddleware<ExternalMcpAuthMiddleware>();
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());
}