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:
@@ -31,6 +31,14 @@ public sealed class WorkerConfig
|
||||
[JsonPropertyName("claude_bin")]
|
||||
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 =>
|
||||
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||
|
||||
|
||||
32
src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs
vendored
Normal file
32
src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
197
src/ClaudeDo.Worker/External/ExternalMcpService.cs
vendored
Normal file
197
src/ClaudeDo.Worker/External/ExternalMcpService.cs
vendored
Normal 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);
|
||||
}
|
||||
@@ -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<PlanningMcpContextAccessor>();
|
||||
builder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||
builder.Services.AddScoped<TaskRepository>();
|
||||
builder.Services.AddScoped<ListRepository>();
|
||||
builder.Services.AddScoped<PlanningMcpService>();
|
||||
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<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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user